diff --git a/.gitattributes b/.gitattributes index 84c7add05..14c3c3594 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,4 +1,3 @@ /Tests export-ignore /phpunit.xml.dist export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore +/.git* export-ignore diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 000000000..4689c4dad --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,8 @@ +Please do not submit any Pull Requests here. They will be closed. +--- + +Please submit your PR here instead: +https://github.com/symfony/symfony + +This repository is what we call a "subtree split": a read-only subset of that main repository. +We're looking forward to your PR there! diff --git a/.github/workflows/close-pull-request.yml b/.github/workflows/close-pull-request.yml new file mode 100644 index 000000000..e55b47817 --- /dev/null +++ b/.github/workflows/close-pull-request.yml @@ -0,0 +1,20 @@ +name: Close Pull Request + +on: + pull_request_target: + types: [opened] + +jobs: + run: + runs-on: ubuntu-latest + steps: + - uses: superbrothers/close-pull-request@v3 + with: + comment: | + Thanks for your Pull Request! We love contributions. + + However, you should instead open your PR on the main repository: + https://github.com/symfony/symfony + + This repository is what we call a "subtree split": a read-only subset of that main repository. + We're looking forward to your PR there! diff --git a/AcceptHeader.php b/AcceptHeader.php index 180e9604c..853c000e0 100644 --- a/AcceptHeader.php +++ b/AcceptHeader.php @@ -46,11 +46,10 @@ public function __construct(array $items) */ public static function fromString(?string $headerValue): self { - $index = 0; - $parts = HeaderUtils::split($headerValue ?? '', ',;='); - return new self(array_map(function ($subParts) use (&$index) { + return new self(array_map(function ($subParts) { + static $index = 0; $part = array_shift($subParts); $attributes = HeaderUtils::combine($subParts); @@ -115,9 +114,7 @@ public function all(): array */ public function filter(string $pattern): self { - return new self(array_filter($this->items, function (AcceptHeaderItem $item) use ($pattern) { - return preg_match($pattern, $item->getValue()); - })); + return new self(array_filter($this->items, fn (AcceptHeaderItem $item) => preg_match($pattern, $item->getValue()))); } /** diff --git a/BinaryFileResponse.php b/BinaryFileResponse.php index 72cef7c0e..41a244b81 100644 --- a/BinaryFileResponse.php +++ b/BinaryFileResponse.php @@ -45,7 +45,7 @@ class BinaryFileResponse extends Response * @param bool $autoEtag Whether the ETag header should be automatically set * @param bool $autoLastModified Whether the Last-Modified header should be automatically set */ - public function __construct(\SplFileInfo|string $file, int $status = 200, array $headers = [], bool $public = true, string $contentDisposition = null, bool $autoEtag = false, bool $autoLastModified = true) + public function __construct(\SplFileInfo|string $file, int $status = 200, array $headers = [], bool $public = true, ?string $contentDisposition = null, bool $autoEtag = false, bool $autoLastModified = true) { parent::__construct(null, $status, $headers); @@ -63,7 +63,7 @@ public function __construct(\SplFileInfo|string $file, int $status = 200, array * * @throws FileException */ - public function setFile(\SplFileInfo|string $file, string $contentDisposition = null, bool $autoEtag = false, bool $autoLastModified = true): static + public function setFile(\SplFileInfo|string $file, ?string $contentDisposition = null, bool $autoEtag = false, bool $autoLastModified = true): static { if (!$file instanceof File) { if ($file instanceof \SplFileInfo) { @@ -125,7 +125,7 @@ public function setChunkSize(int $chunkSize): static */ public function setAutoLastModified(): static { - $this->setLastModified(\DateTime::createFromFormat('U', $this->file->getMTime())); + $this->setLastModified(\DateTimeImmutable::createFromFormat('U', $this->file->getMTime())); return $this; } @@ -217,8 +217,12 @@ public function prepare(Request $request): static } if ('x-accel-redirect' === strtolower($type)) { // Do X-Accel-Mapping substitutions. - // @link https://www.nginx.com/resources/wiki/start/topics/examples/x-accel/#x-accel-redirect - $parts = HeaderUtils::split($request->headers->get('X-Accel-Mapping', ''), ',='); + // @link https://github.com/rack/rack/blob/main/lib/rack/sendfile.rb + // @link https://mattbrictson.com/blog/accelerated-rails-downloads + if (!$request->headers->has('X-Accel-Mapping')) { + throw new \LogicException('The "X-Accel-Mapping" header must be set when "X-Sendfile-Type" is set to "X-Accel-Redirect".'); + } + $parts = HeaderUtils::split($request->headers->get('X-Accel-Mapping'), ',='); foreach ($parts as $part) { [$pathPrefix, $location] = $part; if (str_starts_with($path, $pathPrefix)) { @@ -293,7 +297,7 @@ public function sendContent(): static { try { if (!$this->isSuccessful()) { - return parent::sendContent(); + return $this; } if (0 === $this->maxlen) { @@ -319,7 +323,7 @@ public function sendContent(): static while ('' !== $data) { $read = fwrite($out, $data); if (false === $read || connection_aborted()) { - break; + break 2; } if (0 < $length) { $length -= $read; @@ -358,6 +362,8 @@ public function getContent(): string|false /** * Trust X-Sendfile-Type header. + * + * @return void */ public static function trustXSendfileTypeHeader() { diff --git a/CHANGELOG.md b/CHANGELOG.md index fdea3d67e..3f09854ac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,34 @@ CHANGELOG ========= +6.4 +--- + + * Make `HeaderBag::getDate()`, `Response::getDate()`, `getExpires()` and `getLastModified()` return a `DateTimeImmutable` + * Support root-level `Generator` in `StreamedJsonResponse` + * Add `UriSigner` from the HttpKernel component + * Add `partitioned` flag to `Cookie` (CHIPS Cookie) + * Add argument `bool $flush = true` to `Response::send()` +* Make `MongoDbSessionHandler` instantiable with the mongodb extension directly + +6.3 +--- + + * Calling `ParameterBag::getDigit()`, `getAlnum()`, `getAlpha()` on an `array` throws a `UnexpectedValueException` instead of a `TypeError` + * Add `ParameterBag::getString()` to convert a parameter into string and throw an exception if the value is invalid + * Add `ParameterBag::getEnum()` + * Create migration for session table when pdo handler is used + * Add support for Relay PHP extension for Redis + * The `Response::sendHeaders()` method now takes an optional HTTP status code as parameter, allowing to send informational responses such as Early Hints responses (103 status code) + * Add `IpUtils::isPrivateIp()` + * Add `Request::getPayload(): InputBag` + * Deprecate conversion of invalid values in `ParameterBag::getInt()` and `ParameterBag::getBoolean()`, + * Deprecate ignoring invalid values when using `ParameterBag::filter()`, unless flag `FILTER_NULL_ON_FAILURE` is set + 6.2 --- + * Add `StreamedJsonResponse` class for efficient JSON streaming * The HTTP cache store uses the `xxh128` algorithm * Deprecate calling `JsonResponse::setCallback()`, `Response::setExpires/setLastModified/setEtag()`, `MockArraySessionStorage/NativeSessionStorage::setMetadataBag()`, `NativeSessionStorage::setSaveHandler()` without arguments * Add request matchers under the `Symfony\Component\HttpFoundation\RequestMatcher` namespace diff --git a/Cookie.php b/Cookie.php index 9f43cc2ae..4a3b73608 100644 --- a/Cookie.php +++ b/Cookie.php @@ -32,6 +32,7 @@ class Cookie private bool $raw; private ?string $sameSite = null; + private bool $partitioned = false; private bool $secureDefault = false; private const RESERVED_CHARS_LIST = "=,; \t\r\n\v\f"; @@ -51,6 +52,7 @@ public static function fromString(string $cookie, bool $decode = false): static 'httponly' => false, 'raw' => !$decode, 'samesite' => null, + 'partitioned' => false, ]; $parts = HeaderUtils::split($cookie, ';='); @@ -66,17 +68,20 @@ public static function fromString(string $cookie, bool $decode = false): static $data['expires'] = time() + (int) $data['max-age']; } - return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite']); + return new static($name, $value, $data['expires'], $data['path'], $data['domain'], $data['secure'], $data['httponly'], $data['raw'], $data['samesite'], $data['partitioned']); } /** * @see self::__construct * * @param self::SAMESITE_*|''|null $sameSite + * @param bool $partitioned */ - public static function create(string $name, string $value = null, int|string|\DateTimeInterface $expire = 0, ?string $path = '/', string $domain = null, bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = self::SAMESITE_LAX): self + public static function create(string $name, ?string $value = null, int|string|\DateTimeInterface $expire = 0, ?string $path = '/', ?string $domain = null, ?bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = self::SAMESITE_LAX /* , bool $partitioned = false */): self { - return new self($name, $value, $expire, $path, $domain, $secure, $httpOnly, $raw, $sameSite); + $partitioned = 9 < \func_num_args() ? func_get_arg(9) : false; + + return new self($name, $value, $expire, $path, $domain, $secure, $httpOnly, $raw, $sameSite, $partitioned); } /** @@ -92,7 +97,7 @@ public static function create(string $name, string $value = null, int|string|\Da * * @throws \InvalidArgumentException */ - public function __construct(string $name, string $value = null, int|string|\DateTimeInterface $expire = 0, ?string $path = '/', string $domain = null, bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = self::SAMESITE_LAX) + public function __construct(string $name, ?string $value = null, int|string|\DateTimeInterface $expire = 0, ?string $path = '/', ?string $domain = null, ?bool $secure = null, bool $httpOnly = true, bool $raw = false, ?string $sameSite = self::SAMESITE_LAX, bool $partitioned = false) { // from PHP source code if ($raw && false !== strpbrk($name, self::RESERVED_CHARS_LIST)) { @@ -112,6 +117,7 @@ public function __construct(string $name, string $value = null, int|string|\Date $this->httpOnly = $httpOnly; $this->raw = $raw; $this->sameSite = $this->withSameSite($sameSite)->sameSite; + $this->partitioned = $partitioned; } /** @@ -237,6 +243,17 @@ public function withSameSite(?string $sameSite): static return $cookie; } + /** + * Creates a cookie copy that is tied to the top-level site in cross-site context. + */ + public function withPartitioned(bool $partitioned = true): static + { + $cookie = clone $this; + $cookie->partitioned = $partitioned; + + return $cookie; + } + /** * Returns the cookie as a string. */ @@ -268,11 +285,11 @@ public function __toString(): string $str .= '; domain='.$this->getDomain(); } - if (true === $this->isSecure()) { + if ($this->isSecure()) { $str .= '; secure'; } - if (true === $this->isHttpOnly()) { + if ($this->isHttpOnly()) { $str .= '; httponly'; } @@ -280,6 +297,10 @@ public function __toString(): string $str .= '; samesite='.$this->getSameSite(); } + if ($this->isPartitioned()) { + $str .= '; partitioned'; + } + return $str; } @@ -365,6 +386,14 @@ public function isRaw(): bool return $this->raw; } + /** + * Checks whether the cookie should be tied to the top-level site in cross-site context. + */ + public function isPartitioned(): bool + { + return $this->partitioned; + } + /** * @return self::SAMESITE_*|null */ diff --git a/Exception/BadRequestException.php b/Exception/BadRequestException.php index e4bb309c4..505e1cfde 100644 --- a/Exception/BadRequestException.php +++ b/Exception/BadRequestException.php @@ -14,6 +14,6 @@ /** * Raised when a user sends a malformed request. */ -class BadRequestException extends \UnexpectedValueException implements RequestExceptionInterface +class BadRequestException extends UnexpectedValueException implements RequestExceptionInterface { } diff --git a/Exception/ConflictingHeadersException.php b/Exception/ConflictingHeadersException.php index 5fcf5b426..77aa0e1ee 100644 --- a/Exception/ConflictingHeadersException.php +++ b/Exception/ConflictingHeadersException.php @@ -16,6 +16,6 @@ * * @author Magnus Nordlander */ -class ConflictingHeadersException extends \UnexpectedValueException implements RequestExceptionInterface +class ConflictingHeadersException extends UnexpectedValueException implements RequestExceptionInterface { } diff --git a/Exception/JsonException.php b/Exception/JsonException.php index 5990e760e..6d1e0aecb 100644 --- a/Exception/JsonException.php +++ b/Exception/JsonException.php @@ -16,6 +16,6 @@ * * @author Tobias Nyholm */ -final class JsonException extends \UnexpectedValueException implements RequestExceptionInterface +final class JsonException extends UnexpectedValueException implements RequestExceptionInterface { } diff --git a/Exception/SessionNotFoundException.php b/Exception/SessionNotFoundException.php index 94b0cb69a..80a21bf15 100644 --- a/Exception/SessionNotFoundException.php +++ b/Exception/SessionNotFoundException.php @@ -20,7 +20,7 @@ */ class SessionNotFoundException extends \LogicException implements RequestExceptionInterface { - public function __construct(string $message = 'There is currently no session available.', int $code = 0, \Throwable $previous = null) + public function __construct(string $message = 'There is currently no session available.', int $code = 0, ?\Throwable $previous = null) { parent::__construct($message, $code, $previous); } diff --git a/Exception/SuspiciousOperationException.php b/Exception/SuspiciousOperationException.php index ae7a5f133..4818ef2c8 100644 --- a/Exception/SuspiciousOperationException.php +++ b/Exception/SuspiciousOperationException.php @@ -15,6 +15,6 @@ * Raised when a user has performed an operation that should be considered * suspicious from a security perspective. */ -class SuspiciousOperationException extends \UnexpectedValueException implements RequestExceptionInterface +class SuspiciousOperationException extends UnexpectedValueException implements RequestExceptionInterface { } diff --git a/Exception/UnexpectedValueException.php b/Exception/UnexpectedValueException.php new file mode 100644 index 000000000..c3e6c9d6d --- /dev/null +++ b/Exception/UnexpectedValueException.php @@ -0,0 +1,16 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Exception; + +class UnexpectedValueException extends \UnexpectedValueException +{ +} diff --git a/ExpressionRequestMatcher.php b/ExpressionRequestMatcher.php index 5628ea8bc..fe65e920d 100644 --- a/ExpressionRequestMatcher.php +++ b/ExpressionRequestMatcher.php @@ -29,6 +29,9 @@ class ExpressionRequestMatcher extends RequestMatcher private ExpressionLanguage $language; private Expression|string $expression; + /** + * @return void + */ public function setExpression(ExpressionLanguage $language, Expression|string $expression) { $this->language = $language; @@ -38,7 +41,7 @@ public function setExpression(ExpressionLanguage $language, Expression|string $e public function matches(Request $request): bool { if (!isset($this->language)) { - throw new \LogicException('Unable to match the request as the expression language is not available.'); + throw new \LogicException('Unable to match the request as the expression language is not available. Try running "composer require symfony/expression-language".'); } return $this->language->evaluate($this->expression, [ diff --git a/File/File.php b/File/File.php index e8ce4bcf8..34ca5a537 100644 --- a/File/File.php +++ b/File/File.php @@ -82,7 +82,7 @@ public function getMimeType(): ?string * * @throws FileException if the target file could not be created */ - public function move(string $directory, string $name = null): self + public function move(string $directory, ?string $name = null): self { $target = $this->getTargetFile($directory, $name); @@ -112,7 +112,7 @@ public function getContent(): string return $content; } - protected function getTargetFile(string $directory, string $name = null): self + protected function getTargetFile(string $directory, ?string $name = null): self { if (!is_dir($directory)) { if (false === @mkdir($directory, 0777, true) && !is_dir($directory)) { diff --git a/File/UploadedFile.php b/File/UploadedFile.php index 5bf4cfe87..f475d028d 100644 --- a/File/UploadedFile.php +++ b/File/UploadedFile.php @@ -60,7 +60,7 @@ class UploadedFile extends File * @throws FileException If file_uploads is disabled * @throws FileNotFoundException If the file does not exist */ - public function __construct(string $path, string $originalName, string $mimeType = null, int $error = null, bool $test = false) + public function __construct(string $path, string $originalName, ?string $mimeType = null, ?int $error = null, bool $test = false) { $this->originalName = $this->getName($originalName); $this->mimeType = $mimeType ?: 'application/octet-stream'; @@ -74,7 +74,7 @@ public function __construct(string $path, string $originalName, string $mimeType * Returns the original file name. * * It is extracted from the request from which the file has been uploaded. - * Then it should not be considered as a safe value. + * This should not be considered as a safe value to use for a file name on your servers. */ public function getClientOriginalName(): string { @@ -85,7 +85,7 @@ public function getClientOriginalName(): string * Returns the original file extension. * * It is extracted from the original file name that was uploaded. - * Then it should not be considered as a safe value. + * This should not be considered as a safe value to use for a file name on your servers. */ public function getClientOriginalExtension(): string { @@ -158,7 +158,7 @@ public function isValid(): bool * * @throws FileException if, for any reason, the file could not have been moved */ - public function move(string $directory, string $name = null): File + public function move(string $directory, ?string $name = null): File { if ($this->isValid()) { if ($this->test) { diff --git a/FileBag.php b/FileBag.php index 7ed39408f..b74a02e2e 100644 --- a/FileBag.php +++ b/FileBag.php @@ -31,12 +31,18 @@ public function __construct(array $parameters = []) $this->replace($parameters); } + /** + * @return void + */ public function replace(array $files = []) { $this->parameters = []; $this->add($files); } + /** + * @return void + */ public function set(string $key, mixed $value) { if (!\is_array($value) && !$value instanceof UploadedFile) { @@ -46,6 +52,9 @@ public function set(string $key, mixed $value) parent::set($key, $this->convertFileInformation($value)); } + /** + * @return void + */ public function add(array $files = []) { foreach ($files as $key => $file) { @@ -75,7 +84,7 @@ protected function convertFileInformation(array|UploadedFile $file): array|Uploa $file = new UploadedFile($file['tmp_name'], $file['name'], $file['type'], $file['error'], false); } } else { - $file = array_map(function ($v) { return $v instanceof UploadedFile || \is_array($v) ? $this->convertFileInformation($v) : $v; }, $file); + $file = array_map(fn ($v) => $v instanceof UploadedFile || \is_array($v) ? $this->convertFileInformation($v) : $v, $file); if (array_keys($keys) === $keys) { $file = array_filter($file); } diff --git a/HeaderBag.php b/HeaderBag.php index 0883024b3..4dd777f16 100644 --- a/HeaderBag.php +++ b/HeaderBag.php @@ -18,7 +18,7 @@ * * @implements \IteratorAggregate> */ -class HeaderBag implements \IteratorAggregate, \Countable +class HeaderBag implements \IteratorAggregate, \Countable, \Stringable { protected const UPPER = '_ABCDEFGHIJKLMNOPQRSTUVWXYZ'; protected const LOWER = '-abcdefghijklmnopqrstuvwxyz'; @@ -63,9 +63,9 @@ public function __toString(): string * * @param string|null $key The name of the headers to return or null to get them all * - * @return array>|array + * @return ($key is null ? array> : list) */ - public function all(string $key = null): array + public function all(?string $key = null): array { if (null !== $key) { return $this->headers[strtr($key, self::UPPER, self::LOWER)] ?? []; @@ -86,6 +86,8 @@ public function keys(): array /** * Replaces the current HTTP headers by a new set. + * + * @return void */ public function replace(array $headers = []) { @@ -95,6 +97,8 @@ public function replace(array $headers = []) /** * Adds new headers the current HTTP headers set. + * + * @return void */ public function add(array $headers) { @@ -106,7 +110,7 @@ public function add(array $headers) /** * Returns the first header by name or the default one. */ - public function get(string $key, string $default = null): ?string + public function get(string $key, ?string $default = null): ?string { $headers = $this->all($key); @@ -126,6 +130,8 @@ public function get(string $key, string $default = null): ?string * * @param string|string[]|null $values The value or an array of values * @param bool $replace Whether to replace the actual value or not (true by default) + * + * @return void */ public function set(string $key, string|array|null $values, bool $replace = true) { @@ -170,6 +176,8 @@ public function contains(string $key, string $value): bool /** * Removes a header. + * + * @return void */ public function remove(string $key) { @@ -185,15 +193,17 @@ public function remove(string $key) /** * Returns the HTTP header value converted to a date. * + * @return \DateTimeImmutable|null + * * @throws \RuntimeException When the HTTP header is not parseable */ - public function getDate(string $key, \DateTime $default = null): ?\DateTimeInterface + public function getDate(string $key, ?\DateTimeInterface $default = null): ?\DateTimeInterface { if (null === $value = $this->get($key)) { - return $default; + return null !== $default ? \DateTimeImmutable::createFromInterface($default) : null; } - if (false === $date = \DateTime::createFromFormat(\DATE_RFC2822, $value)) { + if (false === $date = \DateTimeImmutable::createFromFormat(\DATE_RFC2822, $value)) { throw new \RuntimeException(sprintf('The "%s" HTTP header is not parseable (%s).', $key, $value)); } @@ -202,6 +212,8 @@ public function getDate(string $key, \DateTime $default = null): ?\DateTimeInter /** * Adds a custom Cache-Control directive. + * + * @return void */ public function addCacheControlDirective(string $key, bool|string $value = true) { @@ -228,6 +240,8 @@ public function getCacheControlDirective(string $key): bool|string|null /** * Removes a Cache-Control directive. + * + * @return void */ public function removeCacheControlDirective(string $key) { @@ -254,6 +268,9 @@ public function count(): int return \count($this->headers); } + /** + * @return string + */ protected function getCacheControlHeader() { ksort($this->cacheControl); diff --git a/HeaderUtils.php b/HeaderUtils.php index 46b1e6aed..110896e17 100644 --- a/HeaderUtils.php +++ b/HeaderUtils.php @@ -33,17 +33,21 @@ private function __construct() * * Example: * - * HeaderUtils::split("da, en-gb;q=0.8", ",;") + * HeaderUtils::split('da, en-gb;q=0.8', ',;') * // => ['da'], ['en-gb', 'q=0.8']] * * @param string $separators List of characters to split on, ordered by - * precedence, e.g. ",", ";=", or ",;=" + * precedence, e.g. ',', ';=', or ',;=' * * @return array Nested array with as many levels as there are characters in * $separators */ public static function split(string $header, string $separators): array { + if ('' === $separators) { + throw new \InvalidArgumentException('At least one separator must be specified.'); + } + $quotedSeparators = preg_quote($separators, '/'); preg_match_all(' @@ -77,8 +81,8 @@ public static function split(string $header, string $separators): array * * Example: * - * HeaderUtils::combine([["foo", "abc"], ["bar"]]) - * // => ["foo" => "abc", "bar" => true] + * HeaderUtils::combine([['foo', 'abc'], ['bar']]) + * // => ['foo' => 'abc', 'bar' => true] */ public static function combine(array $parts): array { @@ -95,13 +99,13 @@ public static function combine(array $parts): array /** * Joins an associative array into a string for use in an HTTP header. * - * The key and value of each entry are joined with "=", and all entries + * The key and value of each entry are joined with '=', and all entries * are joined with the specified separator and an additional space (for * readability). Values are quoted if necessary. * * Example: * - * HeaderUtils::toString(["foo" => "abc", "bar" => true, "baz" => "a b c"], ",") + * HeaderUtils::toString(['foo' => 'abc', 'bar' => true, 'baz' => 'a b c'], ',') * // => 'foo=abc, bar, baz="a b c"' */ public static function toString(array $assoc, string $separator): string @@ -252,39 +256,40 @@ public static function parseQuery(string $query, bool $ignoreBrackets = false, s private static function groupParts(array $matches, string $separators, bool $first = true): array { $separator = $separators[0]; - $partSeparators = substr($separators, 1); - + $separators = substr($separators, 1) ?: ''; $i = 0; + + if ('' === $separators && !$first) { + $parts = ['']; + + foreach ($matches as $match) { + if (!$i && isset($match['separator'])) { + $i = 1; + $parts[1] = ''; + } else { + $parts[$i] .= self::unquote($match[0]); + } + } + + return $parts; + } + + $parts = []; $partMatches = []; - $previousMatchWasSeparator = false; + foreach ($matches as $match) { - if (!$first && $previousMatchWasSeparator && isset($match['separator']) && $match['separator'] === $separator) { - $previousMatchWasSeparator = true; - $partMatches[$i][] = $match; - } elseif (isset($match['separator']) && $match['separator'] === $separator) { - $previousMatchWasSeparator = true; + if (($match['separator'] ?? null) === $separator) { ++$i; } else { - $previousMatchWasSeparator = false; $partMatches[$i][] = $match; } } - $parts = []; - if ($partSeparators) { - foreach ($partMatches as $matches) { - $parts[] = self::groupParts($matches, $partSeparators, false); - } - } else { - foreach ($partMatches as $matches) { - $parts[] = self::unquote($matches[0][0]); - } - - if (!$first && 2 < \count($parts)) { - $parts = [ - $parts[0], - implode($separator, \array_slice($parts, 1)), - ]; + foreach ($partMatches as $matches) { + if ('' === $separators && '' !== $unquoted = self::unquote($matches[0][0])) { + $parts[] = $unquoted; + } elseif ($groupedParts = self::groupParts($matches, $separators, false)) { + $parts[] = $groupedParts; } } diff --git a/InputBag.php b/InputBag.php index 877ac60f3..5acf35fec 100644 --- a/InputBag.php +++ b/InputBag.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpFoundation; use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpFoundation\Exception\UnexpectedValueException; /** * InputBag is a container for user input values such as $_GET, $_POST, $_REQUEST, and $_COOKIE. @@ -43,7 +44,7 @@ public function get(string $key, mixed $default = null): string|int|float|bool|n /** * Replaces the current input values by a new set. */ - public function replace(array $inputs = []) + public function replace(array $inputs = []): void { $this->parameters = []; $this->add($inputs); @@ -52,7 +53,7 @@ public function replace(array $inputs = []) /** * Adds input values. */ - public function add(array $inputs = []) + public function add(array $inputs = []): void { foreach ($inputs as $input => $value) { $this->set($input, $value); @@ -64,7 +65,7 @@ public function add(array $inputs = []) * * @param string|int|float|bool|array|null $value */ - public function set(string $key, mixed $value) + public function set(string $key, mixed $value): void { if (null !== $value && !\is_scalar($value) && !\is_array($value) && !$value instanceof \Stringable) { throw new \InvalidArgumentException(sprintf('Expected a scalar, or an array as a 2nd argument to "%s()", "%s" given.', __METHOD__, get_debug_type($value))); @@ -73,6 +74,34 @@ public function set(string $key, mixed $value) $this->parameters[$key] = $value; } + /** + * Returns the parameter value converted to an enum. + * + * @template T of \BackedEnum + * + * @param class-string $class + * @param ?T $default + * + * @return ?T + */ + public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum + { + try { + return parent::getEnum($key, $class, $default); + } catch (UnexpectedValueException $e) { + throw new BadRequestException($e->getMessage(), $e->getCode(), $e); + } + } + + /** + * Returns the parameter value converted to string. + */ + public function getString(string $key, string $default = ''): string + { + // Shortcuts the parent method because the validation on scalar is already done in get(). + return (string) $this->get($key, $default); + } + public function filter(string $key, mixed $default = null, int $filter = \FILTER_DEFAULT, mixed $options = []): mixed { $value = $this->has($key) ? $this->all()[$key] : $default; @@ -90,6 +119,22 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER throw new \InvalidArgumentException(sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); } - return filter_var($value, $filter, $options); + $options['flags'] ??= 0; + $nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; + $options['flags'] |= \FILTER_NULL_ON_FAILURE; + + $value = filter_var($value, $filter, $options); + + if (null !== $value || $nullOnFailure) { + return $value; + } + + $method = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; + $method = ($method['object'] ?? null) === $this ? $method['function'] : 'filter'; + $hint = 'filter' === $method ? 'pass' : 'use method "filter()" with'; + + trigger_deprecation('symfony/http-foundation', '6.3', 'Ignoring invalid values when using "%s::%s(\'%s\')" is deprecated and will throw a "%s" in 7.0; '.$hint.' flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.', $this::class, $method, $key, BadRequestException::class); + + return false; } } diff --git a/IpUtils.php b/IpUtils.php index 917eb6e1a..ceab620c2 100644 --- a/IpUtils.php +++ b/IpUtils.php @@ -18,6 +18,21 @@ */ class IpUtils { + public const PRIVATE_SUBNETS = [ + '127.0.0.0/8', // RFC1700 (Loopback) + '10.0.0.0/8', // RFC1918 + '192.168.0.0/16', // RFC1918 + '172.16.0.0/12', // RFC1918 + '169.254.0.0/16', // RFC3927 + '0.0.0.0/8', // RFC5735 + '240.0.0.0/4', // RFC1112 + '::1/128', // Loopback + 'fc00::/7', // Unique Local Address + 'fe80::/10', // Link Local Address + '::ffff:0:0/96', // IPv4 translations + '::/128', // Unspecified address + ]; + private static array $checkedIps = []; /** @@ -60,23 +75,23 @@ public static function checkIp(string $requestIp, string|array $ips): bool public static function checkIp4(string $requestIp, string $ip): bool { $cacheKey = $requestIp.'-'.$ip.'-v4'; - if (isset(self::$checkedIps[$cacheKey])) { - return self::$checkedIps[$cacheKey]; + if (null !== $cacheValue = self::getCacheResult($cacheKey)) { + return $cacheValue; } if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)) { - return self::$checkedIps[$cacheKey] = false; + return self::setCacheResult($cacheKey, false); } if (str_contains($ip, '/')) { [$address, $netmask] = explode('/', $ip, 2); if ('0' === $netmask) { - return self::$checkedIps[$cacheKey] = false !== filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4); + return self::setCacheResult($cacheKey, false !== filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV4)); } if ($netmask < 0 || $netmask > 32) { - return self::$checkedIps[$cacheKey] = false; + return self::setCacheResult($cacheKey, false); } } else { $address = $ip; @@ -84,10 +99,10 @@ public static function checkIp4(string $requestIp, string $ip): bool } if (false === ip2long($address)) { - return self::$checkedIps[$cacheKey] = false; + return self::setCacheResult($cacheKey, false); } - return self::$checkedIps[$cacheKey] = 0 === substr_compare(sprintf('%032b', ip2long($requestIp)), sprintf('%032b', ip2long($address)), 0, $netmask); + return self::setCacheResult($cacheKey, 0 === substr_compare(sprintf('%032b', ip2long($requestIp)), sprintf('%032b', ip2long($address)), 0, $netmask)); } /** @@ -105,8 +120,8 @@ public static function checkIp4(string $requestIp, string $ip): bool public static function checkIp6(string $requestIp, string $ip): bool { $cacheKey = $requestIp.'-'.$ip.'-v6'; - if (isset(self::$checkedIps[$cacheKey])) { - return self::$checkedIps[$cacheKey]; + if (null !== $cacheValue = self::getCacheResult($cacheKey)) { + return $cacheValue; } if (!((\extension_loaded('sockets') && \defined('AF_INET6')) || @inet_pton('::1'))) { @@ -115,14 +130,14 @@ public static function checkIp6(string $requestIp, string $ip): bool // Check to see if we were given a IP4 $requestIp or $ip by mistake if (!filter_var($requestIp, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { - return self::$checkedIps[$cacheKey] = false; + return self::setCacheResult($cacheKey, false); } if (str_contains($ip, '/')) { [$address, $netmask] = explode('/', $ip, 2); if (!filter_var($address, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { - return self::$checkedIps[$cacheKey] = false; + return self::setCacheResult($cacheKey, false); } if ('0' === $netmask) { @@ -130,11 +145,11 @@ public static function checkIp6(string $requestIp, string $ip): bool } if ($netmask < 1 || $netmask > 128) { - return self::$checkedIps[$cacheKey] = false; + return self::setCacheResult($cacheKey, false); } } else { if (!filter_var($ip, \FILTER_VALIDATE_IP, \FILTER_FLAG_IPV6)) { - return self::$checkedIps[$cacheKey] = false; + return self::setCacheResult($cacheKey, false); } $address = $ip; @@ -145,7 +160,7 @@ public static function checkIp6(string $requestIp, string $ip): bool $bytesTest = unpack('n*', @inet_pton($requestIp)); if (!$bytesAddr || !$bytesTest) { - return self::$checkedIps[$cacheKey] = false; + return self::setCacheResult($cacheKey, false); } for ($i = 1, $ceil = ceil($netmask / 16); $i <= $ceil; ++$i) { @@ -153,11 +168,11 @@ public static function checkIp6(string $requestIp, string $ip): bool $left = ($left <= 16) ? $left : 16; $mask = ~(0xFFFF >> $left) & 0xFFFF; if (($bytesAddr[$i] & $mask) != ($bytesTest[$i] & $mask)) { - return self::$checkedIps[$cacheKey] = false; + return self::setCacheResult($cacheKey, false); } } - return self::$checkedIps[$cacheKey] = true; + return self::setCacheResult($cacheKey, true); } /** @@ -191,4 +206,36 @@ public static function anonymize(string $ip): string return $ip; } + + /** + * Checks if an IPv4 or IPv6 address is contained in the list of private IP subnets. + */ + public static function isPrivateIp(string $requestIp): bool + { + return self::checkIp($requestIp, self::PRIVATE_SUBNETS); + } + + private static function getCacheResult(string $cacheKey): ?bool + { + if (isset(self::$checkedIps[$cacheKey])) { + // Move the item last in cache (LRU) + $value = self::$checkedIps[$cacheKey]; + unset(self::$checkedIps[$cacheKey]); + self::$checkedIps[$cacheKey] = $value; + + return self::$checkedIps[$cacheKey]; + } + + return null; + } + + private static function setCacheResult(string $cacheKey, bool $result): bool + { + if (1000 < \count(self::$checkedIps)) { + // stop memory leak if there are many keys + self::$checkedIps = \array_slice(self::$checkedIps, 500, null, true); + } + + return self::$checkedIps[$cacheKey] = $result; + } } diff --git a/JsonResponse.php b/JsonResponse.php index 8dd250a36..93c5751f2 100644 --- a/JsonResponse.php +++ b/JsonResponse.php @@ -75,7 +75,7 @@ public static function fromJsonString(string $data, int $status = 200, array $he * * @throws \InvalidArgumentException When the callback name is not valid */ - public function setCallback(string $callback = null): static + public function setCallback(?string $callback = null): static { if (1 > \func_num_args()) { trigger_deprecation('symfony/http-foundation', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); diff --git a/ParameterBag.php b/ParameterBag.php index 72c8f0949..48fa4b233 100644 --- a/ParameterBag.php +++ b/ParameterBag.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpFoundation; use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpFoundation\Exception\UnexpectedValueException; /** * ParameterBag is a container for key/value pairs. @@ -37,7 +38,7 @@ public function __construct(array $parameters = []) * * @param string|null $key The name of the parameter to return or null to get them all */ - public function all(string $key = null): array + public function all(?string $key = null): array { if (null === $key) { return $this->parameters; @@ -60,6 +61,8 @@ public function keys(): array /** * Replaces the current parameters by a new set. + * + * @return void */ public function replace(array $parameters = []) { @@ -68,6 +71,8 @@ public function replace(array $parameters = []) /** * Adds parameters. + * + * @return void */ public function add(array $parameters = []) { @@ -79,6 +84,9 @@ public function get(string $key, mixed $default = null): mixed return \array_key_exists($key, $this->parameters) ? $this->parameters[$key] : $default; } + /** + * @return void + */ public function set(string $key, mixed $value) { $this->parameters[$key] = $value; @@ -94,6 +102,8 @@ public function has(string $key): bool /** * Removes a parameter. + * + * @return void */ public function remove(string $key) { @@ -105,7 +115,7 @@ public function remove(string $key) */ public function getAlpha(string $key, string $default = ''): string { - return preg_replace('/[^[:alpha:]]/', '', $this->get($key, $default)); + return preg_replace('/[^[:alpha:]]/', '', $this->getString($key, $default)); } /** @@ -113,7 +123,7 @@ public function getAlpha(string $key, string $default = ''): string */ public function getAlnum(string $key, string $default = ''): string { - return preg_replace('/[^[:alnum:]]/', '', $this->get($key, $default)); + return preg_replace('/[^[:alnum:]]/', '', $this->getString($key, $default)); } /** @@ -121,8 +131,20 @@ public function getAlnum(string $key, string $default = ''): string */ public function getDigits(string $key, string $default = ''): string { - // we need to remove - and + because they're allowed in the filter - return str_replace(['-', '+'], '', $this->filter($key, $default, \FILTER_SANITIZE_NUMBER_INT)); + return preg_replace('/[^[:digit:]]/', '', $this->getString($key, $default)); + } + + /** + * Returns the parameter as string. + */ + public function getString(string $key, string $default = ''): string + { + $value = $this->get($key, $default); + if (!\is_scalar($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException(sprintf('Parameter value "%s" cannot be converted to "string".', $key)); + } + + return (string) $value; } /** @@ -130,7 +152,8 @@ public function getDigits(string $key, string $default = ''): string */ public function getInt(string $key, int $default = 0): int { - return (int) $this->get($key, $default); + // In 7.0 remove the fallback to 0, in case of failure an exception will be thrown + return $this->filter($key, $default, \FILTER_VALIDATE_INT, ['flags' => \FILTER_REQUIRE_SCALAR]) ?: 0; } /** @@ -138,13 +161,39 @@ public function getInt(string $key, int $default = 0): int */ public function getBoolean(string $key, bool $default = false): bool { - return $this->filter($key, $default, \FILTER_VALIDATE_BOOL); + return $this->filter($key, $default, \FILTER_VALIDATE_BOOL, ['flags' => \FILTER_REQUIRE_SCALAR]); + } + + /** + * Returns the parameter value converted to an enum. + * + * @template T of \BackedEnum + * + * @param class-string $class + * @param ?T $default + * + * @return ?T + */ + public function getEnum(string $key, string $class, ?\BackedEnum $default = null): ?\BackedEnum + { + $value = $this->get($key); + + if (null === $value) { + return $default; + } + + try { + return $class::from($value); + } catch (\ValueError|\TypeError $e) { + throw new UnexpectedValueException(sprintf('Parameter "%s" cannot be converted to enum: %s.', $key, $e->getMessage()), $e->getCode(), $e); + } } /** * Filter key. * - * @param int $filter FILTER_* constant + * @param int $filter FILTER_* constant + * @param int|array{flags?: int, options?: array} $options Flags from FILTER_* constants * * @see https://php.net/filter-var */ @@ -162,11 +211,31 @@ public function filter(string $key, mixed $default = null, int $filter = \FILTER $options['flags'] = \FILTER_REQUIRE_ARRAY; } + if (\is_object($value) && !$value instanceof \Stringable) { + throw new UnexpectedValueException(sprintf('Parameter value "%s" cannot be filtered.', $key)); + } + if ((\FILTER_CALLBACK & $filter) && !(($options['options'] ?? null) instanceof \Closure)) { throw new \InvalidArgumentException(sprintf('A Closure must be passed to "%s()" when FILTER_CALLBACK is used, "%s" given.', __METHOD__, get_debug_type($options['options'] ?? null))); } - return filter_var($value, $filter, $options); + $options['flags'] ??= 0; + $nullOnFailure = $options['flags'] & \FILTER_NULL_ON_FAILURE; + $options['flags'] |= \FILTER_NULL_ON_FAILURE; + + $value = filter_var($value, $filter, $options); + + if (null !== $value || $nullOnFailure) { + return $value; + } + + $method = debug_backtrace(\DEBUG_BACKTRACE_IGNORE_ARGS | \DEBUG_BACKTRACE_PROVIDE_OBJECT, 2)[1]; + $method = ($method['object'] ?? null) === $this ? $method['function'] : 'filter'; + $hint = 'filter' === $method ? 'pass' : 'use method "filter()" with'; + + trigger_deprecation('symfony/http-foundation', '6.3', 'Ignoring invalid values when using "%s::%s(\'%s\')" is deprecated and will throw an "%s" in 7.0; '.$hint.' flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.', $this::class, $method, $key, UnexpectedValueException::class); + + return false; } /** diff --git a/RedirectResponse.php b/RedirectResponse.php index a001df81d..408629e36 100644 --- a/RedirectResponse.php +++ b/RedirectResponse.php @@ -85,6 +85,7 @@ public function setTargetUrl(string $url): static ', htmlspecialchars($url, \ENT_QUOTES, 'UTF-8'))); $this->headers->set('Location', $url); + $this->headers->set('Content-Type', 'text/html; charset=utf-8'); return $this; } diff --git a/Request.php b/Request.php index 80866bb3b..c45170e3f 100644 --- a/Request.php +++ b/Request.php @@ -11,6 +11,7 @@ namespace Symfony\Component\HttpFoundation; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\Exception\ConflictingHeadersException; use Symfony\Component\HttpFoundation\Exception\JsonException; use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; @@ -89,6 +90,8 @@ class Request /** * Request body parameters ($_POST). * + * @see getPayload() for portability between content types + * * @var InputBag */ public $request; @@ -134,57 +137,57 @@ class Request protected $content; /** - * @var string[] + * @var string[]|null */ protected $languages; /** - * @var string[] + * @var string[]|null */ protected $charsets; /** - * @var string[] + * @var string[]|null */ protected $encodings; /** - * @var string[] + * @var string[]|null */ protected $acceptableContentTypes; /** - * @var string + * @var string|null */ protected $pathInfo; /** - * @var string + * @var string|null */ protected $requestUri; /** - * @var string + * @var string|null */ protected $baseUrl; /** - * @var string + * @var string|null */ protected $basePath; /** - * @var string + * @var string|null */ protected $method; /** - * @var string + * @var string|null */ protected $format; /** - * @var SessionInterface|callable(): SessionInterface + * @var SessionInterface|callable():SessionInterface|null */ protected $session; @@ -199,7 +202,7 @@ class Request protected $defaultLocale = 'en'; /** - * @var array + * @var array|null */ protected static $formats; @@ -210,6 +213,8 @@ class Request private bool $isForwardedValid = true; private bool $isSafeContentPreferred; + private array $trustedValuesCache = []; + private static int $trustedHeaderSet = -1; private const FORWARDED_PARAMS = [ @@ -237,6 +242,9 @@ class Request self::HEADER_X_FORWARDED_PREFIX => 'X_FORWARDED_PREFIX', ]; + /** @var bool */ + private $isIisRewrite = false; + /** * @param array $query The GET parameters * @param array $request The POST parameters @@ -263,6 +271,8 @@ public function __construct(array $query = [], array $request = [], array $attri * @param array $files The FILES parameters * @param array $server The SERVER parameters * @param string|resource|null $content The raw body data + * + * @return void */ public function initialize(array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null) { @@ -317,6 +327,8 @@ public static function createFromGlobals(): static * @param array $files The request files ($_FILES) * @param array $server The server parameters ($_SERVER) * @param string|resource|null $content The raw body data + * + * @throws BadRequestException When the URI is invalid */ public static function create(string $uri, string $method = 'GET', array $parameters = [], array $cookies = [], array $files = [], array $server = [], $content = null): static { @@ -339,7 +351,26 @@ public static function create(string $uri, string $method = 'GET', array $parame $server['PATH_INFO'] = ''; $server['REQUEST_METHOD'] = strtoupper($method); - $components = parse_url($uri); + if (false === ($components = parse_url($uri)) && '/' === ($uri[0] ?? '')) { + trigger_deprecation('symfony/http-foundation', '6.3', 'Calling "%s()" with an invalid URI is deprecated.', __METHOD__); + $components = parse_url($uri.'#'); + unset($components['fragment']); + } + + if (false === $components) { + throw new BadRequestException('Invalid URI.'); + } + + if (false !== ($i = strpos($uri, '\\')) && $i < strcspn($uri, '?#')) { + throw new BadRequestException('Invalid URI: A URI cannot contain a backslash.'); + } + if (\strlen($uri) !== strcspn($uri, "\r\n\t")) { + throw new BadRequestException('Invalid URI: A URI cannot contain CR/LF/TAB characters.'); + } + if ('' !== $uri && (\ord($uri[0]) <= 32 || \ord($uri[-1]) <= 32)) { + throw new BadRequestException('Invalid URI: A URI must not start nor end with ASCII control characters or spaces.'); + } + if (isset($components['host'])) { $server['SERVER_NAME'] = $components['host']; $server['HTTP_HOST'] = $components['host']; @@ -417,6 +448,8 @@ public static function create(string $uri, string $method = 'GET', array $parame * This is mainly useful when you need to override the Request class * to keep BC with an existing system. It should not be used for any * other purpose. + * + * @return void */ public static function setFactory(?callable $callable) { @@ -433,7 +466,7 @@ public static function setFactory(?callable $callable) * @param array|null $files The FILES parameters * @param array|null $server The SERVER parameters */ - public function duplicate(array $query = null, array $request = null, array $attributes = null, array $cookies = null, array $files = null, array $server = null): static + public function duplicate(?array $query = null, ?array $request = null, ?array $attributes = null, ?array $cookies = null, ?array $files = null, ?array $server = null): static { $dup = clone $this; if (null !== $query) { @@ -521,6 +554,8 @@ public function __toString(): string * * It overrides $_GET, $_POST, $_REQUEST, $_SERVER, $_COOKIE. * $_FILES is never overridden, see rfc1867 + * + * @return void */ public function overrideGlobals() { @@ -561,6 +596,8 @@ public function overrideGlobals() * * @param array $proxies A list of trusted proxies, the string 'REMOTE_ADDR' will be replaced with $_SERVER['REMOTE_ADDR'] * @param int $trustedHeaderSet A bit field of Request::HEADER_*, to set which headers to trust from your proxies + * + * @return void */ public static function setTrustedProxies(array $proxies, int $trustedHeaderSet) { @@ -602,12 +639,12 @@ public static function getTrustedHeaderSet(): int * You should only list the hosts you manage using regexs. * * @param array $hostPatterns A list of trusted host patterns + * + * @return void */ public static function setTrustedHosts(array $hostPatterns) { - self::$trustedHostPatterns = array_map(function ($hostPattern) { - return sprintf('{%s}i', $hostPattern); - }, $hostPatterns); + self::$trustedHostPatterns = array_map(fn ($hostPattern) => sprintf('{%s}i', $hostPattern), $hostPatterns); // we need to reset trusted hosts on trusted host patterns change self::$trustedHosts = []; } @@ -650,6 +687,8 @@ public static function normalizeQueryString(?string $qs): string * If these methods are not protected against CSRF, this presents a possible vulnerability. * * The HTTP method can only be overridden when the real HTTP method is POST. + * + * @return void */ public static function enableHttpMethodParameterOverride() { @@ -735,6 +774,9 @@ public function hasSession(bool $skipIfUninitialized = false): bool return null !== $this->session && (!$skipIfUninitialized || $this->session instanceof SessionInterface); } + /** + * @return void + */ public function setSession(SessionInterface $session) { $this->session = $session; @@ -745,9 +787,9 @@ public function setSession(SessionInterface $session) * * @param callable(): SessionInterface $factory */ - public function setSessionFactory(callable $factory) + public function setSessionFactory(callable $factory): void { - $this->session = $factory; + $this->session = $factory(...); } /** @@ -1155,6 +1197,8 @@ public function getHost(): string /** * Sets the request method. + * + * @return void */ public function setMethod(string $method) { @@ -1204,7 +1248,7 @@ public function getMethod(): string } if (!preg_match('/^[A-Z]++$/D', $method)) { - throw new SuspiciousOperationException(sprintf('Invalid method override "%s".', $method)); + throw new SuspiciousOperationException('Invalid HTTP method override.'); } return $this->method = $method; @@ -1276,6 +1320,8 @@ public function getFormat(?string $mimeType): ?string * Associates a format with mime types. * * @param string|string[] $mimeTypes The associated mime types (the preferred one must be the first as it will be used as the content type) + * + * @return void */ public function setFormat(?string $format, string|array $mimeTypes) { @@ -1306,6 +1352,8 @@ public function getRequestFormat(?string $default = 'html'): ?string /** * Sets the request format. + * + * @return void */ public function setRequestFormat(?string $format) { @@ -1336,6 +1384,8 @@ public function getContentTypeFormat(): ?string /** * Sets the default locale. + * + * @return void */ public function setDefaultLocale(string $locale) { @@ -1356,6 +1406,8 @@ public function getDefaultLocale(): string /** * Sets the locale. + * + * @return void */ public function setLocale(string $locale) { @@ -1477,9 +1529,39 @@ public function getContent(bool $asResource = false) return $this->content; } + /** + * Gets the decoded form or json request body. + * + * @throws JsonException When the body cannot be decoded to an array + */ + public function getPayload(): InputBag + { + if ($this->request->count()) { + return clone $this->request; + } + + if ('' === $content = $this->getContent()) { + return new InputBag([]); + } + + try { + $content = json_decode($content, true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new JsonException('Could not decode request body.', $e->getCode(), $e); + } + + if (!\is_array($content)) { + throw new JsonException(sprintf('JSON content was expected to decode to an array, "%s" returned.', get_debug_type($content))); + } + + return new InputBag($content); + } + /** * Gets the request body decoded as array, typically from a JSON payload. * + * @see getPayload() for portability between content types + * * @throws JsonException When the body cannot be decoded to an array */ public function toArray(): array @@ -1524,7 +1606,7 @@ public function isNoCache(): bool */ public function getPreferredFormat(?string $default = 'html'): ?string { - if (null !== $this->preferredFormat || null !== $this->preferredFormat = $this->getRequestFormat(null)) { + if ($this->preferredFormat ??= $this->getRequestFormat(null)) { return $this->preferredFormat; } @@ -1542,7 +1624,7 @@ public function getPreferredFormat(?string $default = 'html'): ?string * * @param string[] $locales An array of ordered available locales */ - public function getPreferredLanguage(array $locales = null): ?string + public function getPreferredLanguage(?array $locales = null): ?string { $preferredLanguages = $this->getLanguages(); @@ -1618,11 +1700,7 @@ public function getLanguages(): array */ public function getCharsets(): array { - if (null !== $this->charsets) { - return $this->charsets; - } - - return $this->charsets = array_map('strval', array_keys(AcceptHeader::fromString($this->headers->get('Accept-Charset'))->all())); + return $this->charsets ??= array_map('strval', array_keys(AcceptHeader::fromString($this->headers->get('Accept-Charset'))->all())); } /** @@ -1632,11 +1710,7 @@ public function getCharsets(): array */ public function getEncodings(): array { - if (null !== $this->encodings) { - return $this->encodings; - } - - return $this->encodings = array_map('strval', array_keys(AcceptHeader::fromString($this->headers->get('Accept-Encoding'))->all())); + return $this->encodings ??= array_map('strval', array_keys(AcceptHeader::fromString($this->headers->get('Accept-Encoding'))->all())); } /** @@ -1646,11 +1720,7 @@ public function getEncodings(): array */ public function getAcceptableContentTypes(): array { - if (null !== $this->acceptableContentTypes) { - return $this->acceptableContentTypes; - } - - return $this->acceptableContentTypes = array_map('strval', array_keys(AcceptHeader::fromString($this->headers->get('Accept'))->all())); + return $this->acceptableContentTypes ??= array_map('strval', array_keys(AcceptHeader::fromString($this->headers->get('Accept'))->all())); } /** @@ -1693,15 +1763,17 @@ public function preferSafeContent(): bool * Copyright (c) 2005-2010 Zend Technologies USA Inc. (https://www.zend.com/) */ + /** + * @return string + */ protected function prepareRequestUri() { $requestUri = ''; - if ('1' == $this->server->get('IIS_WasUrlRewritten') && '' != $this->server->get('UNENCODED_URL')) { + if ($this->isIisRewrite() && '' != $this->server->get('UNENCODED_URL')) { // IIS7 with URL Rewrite: make sure we get the unencoded URL (double slash problem) $requestUri = $this->server->get('UNENCODED_URL'); $this->server->remove('UNENCODED_URL'); - $this->server->remove('IIS_WasUrlRewritten'); } elseif ($this->server->has('REQUEST_URI')) { $requestUri = $this->server->get('REQUEST_URI'); @@ -1861,6 +1933,8 @@ protected function preparePathInfo(): string /** * Initializes HTTP request formats. + * + * @return void */ protected static function initializeFormats() { @@ -1898,7 +1972,13 @@ private function setPhpDefaultLocale(string $locale): void */ private function getUrlencodedPrefix(string $string, string $prefix): ?string { - if (!str_starts_with(rawurldecode($string), $prefix)) { + if ($this->isIisRewrite()) { + // ISS with UrlRewriteModule might report SCRIPT_NAME/PHP_SELF with wrong case + // see https://github.com/php/php-src/issues/11981 + if (0 !== stripos(rawurldecode($string), $prefix)) { + return null; + } + } elseif (!str_starts_with(rawurldecode($string), $prefix)) { return null; } @@ -1937,8 +2017,20 @@ public function isFromTrustedProxy(): bool return self::$trustedProxies && IpUtils::checkIp($this->server->get('REMOTE_ADDR', ''), self::$trustedProxies); } - private function getTrustedValues(int $type, string $ip = null): array + /** + * This method is rather heavy because it splits and merges headers, and it's called by many other methods such as + * getPort(), isSecure(), getHost(), getClientIps(), getBaseUrl() etc. Thus, we try to cache the results for + * best performance. + */ + private function getTrustedValues(int $type, ?string $ip = null): array { + $cacheKey = $type."\0".((self::$trustedHeaderSet & $type) ? $this->headers->get(self::TRUSTED_HEADERS[$type]) : ''); + $cacheKey .= "\0".$ip."\0".$this->headers->get(self::TRUSTED_HEADERS[self::HEADER_FORWARDED]); + + if (isset($this->trustedValuesCache[$cacheKey])) { + return $this->trustedValuesCache[$cacheKey]; + } + $clientValues = []; $forwardedValues = []; @@ -1951,7 +2043,6 @@ private function getTrustedValues(int $type, string $ip = null): array if ((self::$trustedHeaderSet & self::HEADER_FORWARDED) && (isset(self::FORWARDED_PARAMS[$type])) && $this->headers->has(self::TRUSTED_HEADERS[self::HEADER_FORWARDED])) { $forwarded = $this->headers->get(self::TRUSTED_HEADERS[self::HEADER_FORWARDED]); $parts = HeaderUtils::split($forwarded, ',;='); - $forwardedValues = []; $param = self::FORWARDED_PARAMS[$type]; foreach ($parts as $subParts) { if (null === $v = HeaderUtils::combine($subParts)[$param] ?? null) { @@ -1973,15 +2064,15 @@ private function getTrustedValues(int $type, string $ip = null): array } if ($forwardedValues === $clientValues || !$clientValues) { - return $forwardedValues; + return $this->trustedValuesCache[$cacheKey] = $forwardedValues; } if (!$forwardedValues) { - return $clientValues; + return $this->trustedValuesCache[$cacheKey] = $clientValues; } if (!$this->isForwardedValid) { - return null !== $ip ? ['0.0.0.0', $ip] : []; + return $this->trustedValuesCache[$cacheKey] = null !== $ip ? ['0.0.0.0', $ip] : []; } $this->isForwardedValid = false; @@ -2027,4 +2118,20 @@ private function normalizeAndFilterClientIps(array $clientIps, string $ip): arra // Now the IP chain contains only untrusted proxies and the client IP return $clientIps ? array_reverse($clientIps) : [$firstTrustedIp]; } + + /** + * Is this IIS with UrlRewriteModule? + * + * This method consumes, caches and removed the IIS_WasUrlRewritten env var, + * so we don't inherit it to sub-requests. + */ + private function isIisRewrite(): bool + { + if (1 === $this->server->getInt('IIS_WasUrlRewritten')) { + $this->isIisRewrite = true; + $this->server->remove('IIS_WasUrlRewritten'); + } + + return $this->isIisRewrite; + } } diff --git a/RequestMatcher.php b/RequestMatcher.php index c2addd36e..b3ca3715d 100644 --- a/RequestMatcher.php +++ b/RequestMatcher.php @@ -51,7 +51,7 @@ class RequestMatcher implements RequestMatcherInterface * @param string|string[]|null $ips * @param string|string[]|null $schemes */ - public function __construct(string $path = null, string $host = null, string|array $methods = null, string|array $ips = null, array $attributes = [], string|array $schemes = null, int $port = null) + public function __construct(?string $path = null, ?string $host = null, string|array|null $methods = null, string|array|null $ips = null, array $attributes = [], string|array|null $schemes = null, ?int $port = null) { $this->matchPath($path); $this->matchHost($host); @@ -69,6 +69,8 @@ public function __construct(string $path = null, string $host = null, string|arr * Adds a check for the HTTP scheme. * * @param string|string[]|null $scheme An HTTP scheme or an array of HTTP schemes + * + * @return void */ public function matchScheme(string|array|null $scheme) { @@ -77,6 +79,8 @@ public function matchScheme(string|array|null $scheme) /** * Adds a check for the URL host name. + * + * @return void */ public function matchHost(?string $regexp) { @@ -84,9 +88,11 @@ public function matchHost(?string $regexp) } /** - * Adds a check for the the URL port. + * Adds a check for the URL port. * * @param int|null $port The port number to connect to + * + * @return void */ public function matchPort(?int $port) { @@ -95,6 +101,8 @@ public function matchPort(?int $port) /** * Adds a check for the URL path info. + * + * @return void */ public function matchPath(?string $regexp) { @@ -105,6 +113,8 @@ public function matchPath(?string $regexp) * Adds a check for the client IP. * * @param string $ip A specific IP address or a range specified using IP/netmask like 192.168.1.0/24 + * + * @return void */ public function matchIp(string $ip) { @@ -115,20 +125,22 @@ public function matchIp(string $ip) * Adds a check for the client IP. * * @param string|string[]|null $ips A specific IP address or a range specified using IP/netmask like 192.168.1.0/24 + * + * @return void */ public function matchIps(string|array|null $ips) { $ips = null !== $ips ? (array) $ips : []; - $this->ips = array_reduce($ips, static function (array $ips, string $ip) { - return array_merge($ips, preg_split('/\s*,\s*/', $ip)); - }, []); + $this->ips = array_reduce($ips, static fn (array $ips, string $ip) => array_merge($ips, preg_split('/\s*,\s*/', $ip)), []); } /** * Adds a check for the HTTP method. * * @param string|string[]|null $method An HTTP method or an array of HTTP methods + * + * @return void */ public function matchMethod(string|array|null $method) { @@ -137,6 +149,8 @@ public function matchMethod(string|array|null $method) /** * Adds a check for request attribute. + * + * @return void */ public function matchAttribute(string $key, string $regexp) { diff --git a/RequestMatcher/IpsRequestMatcher.php b/RequestMatcher/IpsRequestMatcher.php index 2ddff038d..333612e2f 100644 --- a/RequestMatcher/IpsRequestMatcher.php +++ b/RequestMatcher/IpsRequestMatcher.php @@ -30,9 +30,7 @@ class IpsRequestMatcher implements RequestMatcherInterface */ public function __construct(array|string $ips) { - $this->ips = array_reduce((array) $ips, static function (array $ips, string $ip) { - return array_merge($ips, preg_split('/\s*,\s*/', $ip)); - }, []); + $this->ips = array_reduce((array) $ips, static fn (array $ips, string $ip) => array_merge($ips, preg_split('/\s*,\s*/', $ip)), []); } public function matches(Request $request): bool diff --git a/RequestMatcher/IsJsonRequestMatcher.php b/RequestMatcher/IsJsonRequestMatcher.php index 5da46840f..875f992be 100644 --- a/RequestMatcher/IsJsonRequestMatcher.php +++ b/RequestMatcher/IsJsonRequestMatcher.php @@ -23,12 +23,6 @@ class IsJsonRequestMatcher implements RequestMatcherInterface { public function matches(Request $request): bool { - try { - json_decode($request->getContent(), true, 512, \JSON_BIGINT_AS_STRING | \JSON_THROW_ON_ERROR); - } catch (\JsonException) { - return false; - } - - return true; + return json_validate($request->getContent()); } } diff --git a/RequestMatcher/MethodRequestMatcher.php b/RequestMatcher/MethodRequestMatcher.php index c7a915980..b37f6e3c8 100644 --- a/RequestMatcher/MethodRequestMatcher.php +++ b/RequestMatcher/MethodRequestMatcher.php @@ -32,9 +32,7 @@ class MethodRequestMatcher implements RequestMatcherInterface */ public function __construct(array|string $methods) { - $this->methods = array_reduce(array_map('strtoupper', (array) $methods), static function (array $methods, string $method) { - return array_merge($methods, preg_split('/\s*,\s*/', $method)); - }, []); + $this->methods = array_reduce(array_map('strtoupper', (array) $methods), static fn (array $methods, string $method) => array_merge($methods, preg_split('/\s*,\s*/', $method)), []); } public function matches(Request $request): bool diff --git a/RequestMatcher/SchemeRequestMatcher.php b/RequestMatcher/SchemeRequestMatcher.php index 4f5eabc2c..9c9cd58b9 100644 --- a/RequestMatcher/SchemeRequestMatcher.php +++ b/RequestMatcher/SchemeRequestMatcher.php @@ -32,9 +32,7 @@ class SchemeRequestMatcher implements RequestMatcherInterface */ public function __construct(array|string $schemes) { - $this->schemes = array_reduce(array_map('strtolower', (array) $schemes), static function (array $schemes, string $scheme) { - return array_merge($schemes, preg_split('/\s*,\s*/', $scheme)); - }, []); + $this->schemes = array_reduce(array_map('strtolower', (array) $schemes), static fn (array $schemes, string $scheme) => array_merge($schemes, preg_split('/\s*,\s*/', $scheme)), []); } public function matches(Request $request): bool diff --git a/RequestStack.php b/RequestStack.php index 6b13fa1e6..5aa8ba793 100644 --- a/RequestStack.php +++ b/RequestStack.php @@ -31,6 +31,8 @@ class RequestStack * * This method should generally not be called directly as the stack * management should be taken care of by the application itself. + * + * @return void */ public function push(Request $request) { diff --git a/Response.php b/Response.php index c141cbc0b..a43e7a9ac 100644 --- a/Response.php +++ b/Response.php @@ -211,6 +211,11 @@ class Response 511 => 'Network Authentication Required', // RFC6585 ]; + /** + * Tracks headers already sent in informational responses. + */ + private array $sentHeaders; + /** * @param int $status The HTTP status code (200 "OK" by default) * @@ -281,7 +286,7 @@ public function prepare(Request $request): static $charset = $this->charset ?: 'UTF-8'; if (!$headers->has('Content-Type')) { $headers->set('Content-Type', 'text/html; charset='.$charset); - } elseif (0 === stripos($headers->get('Content-Type'), 'text/') && false === stripos($headers->get('Content-Type'), 'charset')) { + } elseif (0 === stripos($headers->get('Content-Type') ?? '', 'text/') && false === stripos($headers->get('Content-Type') ?? '', 'charset')) { // add the charset $headers->set('Content-Type', $headers->get('Content-Type').'; charset='.$charset); } @@ -326,21 +331,52 @@ public function prepare(Request $request): static /** * Sends HTTP headers. * + * @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null + * * @return $this */ - public function sendHeaders(): static + public function sendHeaders(/* int $statusCode = null */): static { // headers have already been sent by the developer if (headers_sent()) { return $this; } + $statusCode = \func_num_args() > 0 ? func_get_arg(0) : null; + $informationalResponse = $statusCode >= 100 && $statusCode < 200; + if ($informationalResponse && !\function_exists('headers_send')) { + // skip informational responses if not supported by the SAPI + return $this; + } + // headers foreach ($this->headers->allPreserveCaseWithoutCookies() as $name => $values) { + $newValues = $values; + $replace = false; + + // As recommended by RFC 8297, PHP automatically copies headers from previous 103 responses, we need to deal with that if headers changed + $previousValues = $this->sentHeaders[$name] ?? null; + if ($previousValues === $values) { + // Header already sent in a previous response, it will be automatically copied in this response by PHP + continue; + } + $replace = 0 === strcasecmp($name, 'Content-Type'); - foreach ($values as $value) { + + if (null !== $previousValues && array_diff($previousValues, $values)) { + header_remove($name); + $previousValues = null; + } + + $newValues = null === $previousValues ? $values : array_diff($values, $previousValues); + + foreach ($newValues as $value) { header($name.': '.$value, $replace, $this->statusCode); } + + if ($informationalResponse) { + $this->sentHeaders[$name] = $values; + } } // cookies @@ -348,8 +384,16 @@ public function sendHeaders(): static header('Set-Cookie: '.$cookie, false, $this->statusCode); } + if ($informationalResponse) { + headers_send($statusCode); + + return $this; + } + + $statusCode ??= $this->statusCode; + // status - header(sprintf('HTTP/%s %s %s', $this->version, $this->statusCode, $this->statusText), true, $this->statusCode); + header(sprintf('HTTP/%s %s %s', $this->version, $statusCode, $this->statusText), true, $statusCode); return $this; } @@ -369,18 +413,25 @@ public function sendContent(): static /** * Sends HTTP headers and content. * + * @param bool $flush Whether output buffers should be flushed + * * @return $this */ - public function send(): static + public function send(/* bool $flush = true */): static { $this->sendHeaders(); $this->sendContent(); + $flush = 1 <= \func_num_args() ? func_get_arg(0) : true; + if (!$flush) { + return $this; + } + if (\function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); } elseif (\function_exists('litespeed_finish_request')) { litespeed_finish_request(); - } elseif (!\in_array(\PHP_SAPI, ['cli', 'phpdbg'], true)) { + } elseif (!\in_array(\PHP_SAPI, ['cli', 'phpdbg', 'embed'], true)) { static::closeOutputBuffers(0, true); flush(); } @@ -444,7 +495,7 @@ public function getProtocolVersion(): string * * @final */ - public function setStatusCode(int $code, string $text = null): static + public function setStatusCode(int $code, ?string $text = null): static { $this->statusCode = $code; if ($this->isInvalid()) { @@ -457,12 +508,6 @@ public function setStatusCode(int $code, string $text = null): static return $this; } - if (false === $text) { - $this->statusText = ''; - - return $this; - } - $this->statusText = $text; return $this; @@ -641,7 +686,7 @@ public function mustRevalidate(): bool * * @final */ - public function getDate(): ?\DateTimeInterface + public function getDate(): ?\DateTimeImmutable { return $this->headers->getDate('Date'); } @@ -655,10 +700,7 @@ public function getDate(): ?\DateTimeInterface */ public function setDate(\DateTimeInterface $date): static { - if ($date instanceof \DateTime) { - $date = \DateTimeImmutable::createFromMutable($date); - } - + $date = \DateTimeImmutable::createFromInterface($date); $date = $date->setTimezone(new \DateTimeZone('UTC')); $this->headers->set('Date', $date->format('D, d M Y H:i:s').' GMT'); @@ -699,13 +741,13 @@ public function expire(): static * * @final */ - public function getExpires(): ?\DateTimeInterface + public function getExpires(): ?\DateTimeImmutable { try { return $this->headers->getDate('Expires'); } catch (\RuntimeException) { // according to RFC 2616 invalid date formats (e.g. "0" and "-1") must be treated as in the past - return \DateTime::createFromFormat('U', time() - 172800); + return \DateTimeImmutable::createFromFormat('U', time() - 172800); } } @@ -718,7 +760,7 @@ public function getExpires(): ?\DateTimeInterface * * @final */ - public function setExpires(\DateTimeInterface $date = null): static + public function setExpires(?\DateTimeInterface $date = null): static { if (1 > \func_num_args()) { trigger_deprecation('symfony/http-foundation', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); @@ -729,10 +771,7 @@ public function setExpires(\DateTimeInterface $date = null): static return $this; } - if ($date instanceof \DateTime) { - $date = \DateTimeImmutable::createFromMutable($date); - } - + $date = \DateTimeImmutable::createFromInterface($date); $date = $date->setTimezone(new \DateTimeZone('UTC')); $this->headers->set('Expires', $date->format('D, d M Y H:i:s').' GMT'); @@ -888,7 +927,7 @@ public function setClientTtl(int $seconds): static * * @final */ - public function getLastModified(): ?\DateTimeInterface + public function getLastModified(): ?\DateTimeImmutable { return $this->headers->getDate('Last-Modified'); } @@ -902,7 +941,7 @@ public function getLastModified(): ?\DateTimeInterface * * @final */ - public function setLastModified(\DateTimeInterface $date = null): static + public function setLastModified(?\DateTimeInterface $date = null): static { if (1 > \func_num_args()) { trigger_deprecation('symfony/http-foundation', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); @@ -913,10 +952,7 @@ public function setLastModified(\DateTimeInterface $date = null): static return $this; } - if ($date instanceof \DateTime) { - $date = \DateTimeImmutable::createFromMutable($date); - } - + $date = \DateTimeImmutable::createFromInterface($date); $date = $date->setTimezone(new \DateTimeZone('UTC')); $this->headers->set('Last-Modified', $date->format('D, d M Y H:i:s').' GMT'); @@ -943,7 +979,7 @@ public function getEtag(): ?string * * @final */ - public function setEtag(string $etag = null, bool $weak = false): static + public function setEtag(?string $etag = null, bool $weak = false): static { if (1 > \func_num_args()) { trigger_deprecation('symfony/http-foundation', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); @@ -1246,7 +1282,7 @@ public function isNotFound(): bool * * @final */ - public function isRedirect(string $location = null): bool + public function isRedirect(?string $location = null): bool { return \in_array($this->statusCode, [201, 301, 302, 303, 307, 308]) && (null === $location ?: $location == $this->headers->get('Location')); } diff --git a/ResponseHeaderBag.php b/ResponseHeaderBag.php index 9e8c5793a..376357d01 100644 --- a/ResponseHeaderBag.php +++ b/ResponseHeaderBag.php @@ -55,6 +55,9 @@ public function allPreserveCase(): array return $headers; } + /** + * @return array + */ public function allPreserveCaseWithoutCookies() { $headers = $this->allPreserveCase(); @@ -65,6 +68,9 @@ public function allPreserveCaseWithoutCookies() return $headers; } + /** + * @return void + */ public function replace(array $headers = []) { $this->headerNames = []; @@ -80,7 +86,7 @@ public function replace(array $headers = []) } } - public function all(string $key = null): array + public function all(?string $key = null): array { $headers = parent::all(); @@ -97,6 +103,9 @@ public function all(string $key = null): array return $headers; } + /** + * @return void + */ public function set(string $key, string|array|null $values, bool $replace = true) { $uniqueKey = strtr($key, self::UPPER, self::LOWER); @@ -125,6 +134,9 @@ public function set(string $key, string|array|null $values, bool $replace = true } } + /** + * @return void + */ public function remove(string $key) { $uniqueKey = strtr($key, self::UPPER, self::LOWER); @@ -157,6 +169,9 @@ public function getCacheControlDirective(string $key): bool|string|null return $this->computedCacheControl[$key] ?? null; } + /** + * @return void + */ public function setCookie(Cookie $cookie) { $this->cookies[$cookie->getDomain()][$cookie->getPath()][$cookie->getName()] = $cookie; @@ -165,8 +180,10 @@ public function setCookie(Cookie $cookie) /** * Removes a cookie from the array, but does not unset it in the browser. + * + * @return void */ - public function removeCookie(string $name, ?string $path = '/', string $domain = null) + public function removeCookie(string $name, ?string $path = '/', ?string $domain = null) { $path ??= '/'; @@ -216,14 +233,22 @@ public function getCookies(string $format = self::COOKIES_FLAT): array /** * Clears a cookie in the browser. + * + * @param bool $partitioned + * + * @return void */ - public function clearCookie(string $name, ?string $path = '/', string $domain = null, bool $secure = false, bool $httpOnly = true, string $sameSite = null) + public function clearCookie(string $name, ?string $path = '/', ?string $domain = null, bool $secure = false, bool $httpOnly = true, ?string $sameSite = null /* , bool $partitioned = false */) { - $this->setCookie(new Cookie($name, null, 1, $path, $domain, $secure, $httpOnly, false, $sameSite)); + $partitioned = 6 < \func_num_args() ? \func_get_arg(6) : false; + + $this->setCookie(new Cookie($name, null, 1, $path, $domain, $secure, $httpOnly, false, $sameSite, $partitioned)); } /** * @see HeaderUtils::makeDisposition() + * + * @return string */ public function makeDisposition(string $disposition, string $filename, string $filenameFallback = '') { diff --git a/ServerBag.php b/ServerBag.php index 5b0fc8ac4..09fc38664 100644 --- a/ServerBag.php +++ b/ServerBag.php @@ -29,7 +29,7 @@ public function getHeaders(): array foreach ($this->parameters as $key => $value) { if (str_starts_with($key, 'HTTP_')) { $headers[substr($key, 5)] = $value; - } elseif (\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true)) { + } elseif (\in_array($key, ['CONTENT_TYPE', 'CONTENT_LENGTH', 'CONTENT_MD5'], true) && '' !== $value) { $headers[$key] = $value; } } @@ -49,7 +49,7 @@ public function getHeaders(): array * RewriteCond %{HTTP:Authorization} .+ * RewriteRule ^ - [E=HTTP_AUTHORIZATION:%0] * RewriteCond %{REQUEST_FILENAME} !-f - * RewriteRule ^(.*)$ app.php [QSA,L] + * RewriteRule ^(.*)$ index.php [QSA,L] */ $authorizationHeader = null; diff --git a/Session/Attribute/AttributeBag.php b/Session/Attribute/AttributeBag.php index 665961757..ad5a6590a 100644 --- a/Session/Attribute/AttributeBag.php +++ b/Session/Attribute/AttributeBag.php @@ -36,11 +36,17 @@ public function getName(): string return $this->name; } + /** + * @return void + */ public function setName(string $name) { $this->name = $name; } + /** + * @return void + */ public function initialize(array &$attributes) { $this->attributes = &$attributes; @@ -61,6 +67,9 @@ public function get(string $name, mixed $default = null): mixed return \array_key_exists($name, $this->attributes) ? $this->attributes[$name] : $default; } + /** + * @return void + */ public function set(string $name, mixed $value) { $this->attributes[$name] = $value; @@ -71,6 +80,9 @@ public function all(): array return $this->attributes; } + /** + * @return void + */ public function replace(array $attributes) { $this->attributes = []; diff --git a/Session/Attribute/AttributeBagInterface.php b/Session/Attribute/AttributeBagInterface.php index 31a946444..e8cd0b5a4 100644 --- a/Session/Attribute/AttributeBagInterface.php +++ b/Session/Attribute/AttributeBagInterface.php @@ -32,6 +32,8 @@ public function get(string $name, mixed $default = null): mixed; /** * Sets an attribute. + * + * @return void */ public function set(string $name, mixed $value); @@ -42,6 +44,9 @@ public function set(string $name, mixed $value); */ public function all(): array; + /** + * @return void + */ public function replace(array $attributes); /** diff --git a/Session/Flash/AutoExpireFlashBag.php b/Session/Flash/AutoExpireFlashBag.php index 00b1ac948..80bbeda0f 100644 --- a/Session/Flash/AutoExpireFlashBag.php +++ b/Session/Flash/AutoExpireFlashBag.php @@ -35,11 +35,17 @@ public function getName(): string return $this->name; } + /** + * @return void + */ public function setName(string $name) { $this->name = $name; } + /** + * @return void + */ public function initialize(array &$flashes) { $this->flashes = &$flashes; @@ -51,6 +57,9 @@ public function initialize(array &$flashes) $this->flashes['new'] = []; } + /** + * @return void + */ public function add(string $type, mixed $message) { $this->flashes['new'][$type][] = $message; @@ -90,11 +99,17 @@ public function all(): array return $return; } + /** + * @return void + */ public function setAll(array $messages) { $this->flashes['new'] = $messages; } + /** + * @return void + */ public function set(string $type, string|array $messages) { $this->flashes['new'][$type] = (array) $messages; diff --git a/Session/Flash/FlashBag.php b/Session/Flash/FlashBag.php index a30d9528d..659d59d18 100644 --- a/Session/Flash/FlashBag.php +++ b/Session/Flash/FlashBag.php @@ -35,16 +35,25 @@ public function getName(): string return $this->name; } + /** + * @return void + */ public function setName(string $name) { $this->name = $name; } + /** + * @return void + */ public function initialize(array &$flashes) { $this->flashes = &$flashes; } + /** + * @return void + */ public function add(string $type, mixed $message) { $this->flashes[$type][] = $message; @@ -81,11 +90,17 @@ public function all(): array return $return; } + /** + * @return void + */ public function set(string $type, string|array $messages) { $this->flashes[$type] = (array) $messages; } + /** + * @return void + */ public function setAll(array $messages) { $this->flashes = $messages; diff --git a/Session/Flash/FlashBagInterface.php b/Session/Flash/FlashBagInterface.php index cd10a23f3..bbcf7f8b7 100644 --- a/Session/Flash/FlashBagInterface.php +++ b/Session/Flash/FlashBagInterface.php @@ -22,11 +22,15 @@ interface FlashBagInterface extends SessionBagInterface { /** * Adds a flash message for the given type. + * + * @return void */ public function add(string $type, mixed $message); /** * Registers one or more messages for a given type. + * + * @return void */ public function set(string $type, string|array $messages); @@ -57,6 +61,8 @@ public function all(): array; /** * Sets all flash messages. + * + * @return void */ public function setAll(array $messages); diff --git a/Session/Session.php b/Session/Session.php index a55dde482..5b6db1754 100644 --- a/Session/Session.php +++ b/Session/Session.php @@ -40,7 +40,7 @@ class Session implements FlashBagAwareSessionInterface, \IteratorAggregate, \Cou private int $usageIndex = 0; private ?\Closure $usageReporter; - public function __construct(SessionStorageInterface $storage = null, AttributeBagInterface $attributes = null, FlashBagInterface $flashes = null, callable $usageReporter = null) + public function __construct(?SessionStorageInterface $storage = null, ?AttributeBagInterface $attributes = null, ?FlashBagInterface $flashes = null, ?callable $usageReporter = null) { $this->storage = $storage ?? new NativeSessionStorage(); $this->usageReporter = null === $usageReporter ? null : $usageReporter(...); @@ -69,6 +69,9 @@ public function get(string $name, mixed $default = null): mixed return $this->getAttributeBag()->get($name, $default); } + /** + * @return void + */ public function set(string $name, mixed $value) { $this->getAttributeBag()->set($name, $value); @@ -79,6 +82,9 @@ public function all(): array return $this->getAttributeBag()->all(); } + /** + * @return void + */ public function replace(array $attributes) { $this->getAttributeBag()->replace($attributes); @@ -89,6 +95,9 @@ public function remove(string $name): mixed return $this->getAttributeBag()->remove($name); } + /** + * @return void + */ public function clear() { $this->getAttributeBag()->clear(); @@ -142,18 +151,21 @@ public function isEmpty(): bool return true; } - public function invalidate(int $lifetime = null): bool + public function invalidate(?int $lifetime = null): bool { $this->storage->clear(); return $this->migrate(true, $lifetime); } - public function migrate(bool $destroy = false, int $lifetime = null): bool + public function migrate(bool $destroy = false, ?int $lifetime = null): bool { return $this->storage->regenerate($destroy, $lifetime); } + /** + * @return void + */ public function save() { $this->storage->save(); @@ -164,6 +176,9 @@ public function getId(): string return $this->storage->getId(); } + /** + * @return void + */ public function setId(string $id) { if ($this->storage->getId() !== $id) { @@ -176,6 +191,9 @@ public function getName(): string return $this->storage->getName(); } + /** + * @return void + */ public function setName(string $name) { $this->storage->setName($name); @@ -191,6 +209,9 @@ public function getMetadataBag(): MetadataBag return $this->storage->getMetadataBag(); } + /** + * @return void + */ public function registerBag(SessionBagInterface $bag) { $this->storage->registerBag(new SessionBagProxy($bag, $this->data, $this->usageIndex, $this->usageReporter)); diff --git a/Session/SessionBagInterface.php b/Session/SessionBagInterface.php index 821645d9b..e1c250554 100644 --- a/Session/SessionBagInterface.php +++ b/Session/SessionBagInterface.php @@ -25,6 +25,8 @@ public function getName(): string; /** * Initializes the Bag. + * + * @return void */ public function initialize(array &$array); diff --git a/Session/SessionFactory.php b/Session/SessionFactory.php index cdb6af51e..c06ed4b7d 100644 --- a/Session/SessionFactory.php +++ b/Session/SessionFactory.php @@ -26,7 +26,7 @@ class SessionFactory implements SessionFactoryInterface private SessionStorageFactoryInterface $storageFactory; private ?\Closure $usageReporter; - public function __construct(RequestStack $requestStack, SessionStorageFactoryInterface $storageFactory, callable $usageReporter = null) + public function __construct(RequestStack $requestStack, SessionStorageFactoryInterface $storageFactory, ?callable $usageReporter = null) { $this->requestStack = $requestStack; $this->storageFactory = $storageFactory; diff --git a/Session/SessionInterface.php b/Session/SessionInterface.php index da2b3a37d..07785a6f4 100644 --- a/Session/SessionInterface.php +++ b/Session/SessionInterface.php @@ -34,6 +34,8 @@ public function getId(): string; /** * Sets the session ID. + * + * @return void */ public function setId(string $id); @@ -44,6 +46,8 @@ public function getName(): string; /** * Sets the session name. + * + * @return void */ public function setName(string $name); @@ -58,7 +62,7 @@ public function setName(string $name); * to expire with browser session. Time is in seconds, and is * not a Unix timestamp. */ - public function invalidate(int $lifetime = null): bool; + public function invalidate(?int $lifetime = null): bool; /** * Migrates the current session to a new session id while maintaining all @@ -70,7 +74,7 @@ public function invalidate(int $lifetime = null): bool; * to expire with browser session. Time is in seconds, and is * not a Unix timestamp. */ - public function migrate(bool $destroy = false, int $lifetime = null): bool; + public function migrate(bool $destroy = false, ?int $lifetime = null): bool; /** * Force the session to be saved and closed. @@ -78,6 +82,8 @@ public function migrate(bool $destroy = false, int $lifetime = null): bool; * This method is generally not required for real sessions as * the session will be automatically saved at the end of * code execution. + * + * @return void */ public function save(); @@ -93,6 +99,8 @@ public function get(string $name, mixed $default = null): mixed; /** * Sets an attribute. + * + * @return void */ public function set(string $name, mixed $value); @@ -103,6 +111,8 @@ public function all(): array; /** * Sets attributes. + * + * @return void */ public function replace(array $attributes); @@ -115,6 +125,8 @@ public function remove(string $name): mixed; /** * Clears all attributes. + * + * @return void */ public function clear(); @@ -125,6 +137,8 @@ public function isStarted(): bool; /** * Registers a SessionBagInterface with the session. + * + * @return void */ public function registerBag(SessionBagInterface $bag); diff --git a/Session/SessionUtils.php b/Session/SessionUtils.php index b5bce4a88..504c5848e 100644 --- a/Session/SessionUtils.php +++ b/Session/SessionUtils.php @@ -25,7 +25,7 @@ final class SessionUtils * Finds the session header amongst the headers that are to be sent, removes it, and returns * it so the caller can process it further. */ - public static function popSessionCookie(string $sessionName, string $sessionId): ?string + public static function popSessionCookie(string $sessionName, #[\SensitiveParameter] string $sessionId): ?string { $sessionCookie = null; $sessionCookiePrefix = sprintf(' %s=', urlencode($sessionName)); diff --git a/Session/Storage/Handler/AbstractSessionHandler.php b/Session/Storage/Handler/AbstractSessionHandler.php index 88e513c5d..288c24232 100644 --- a/Session/Storage/Handler/AbstractSessionHandler.php +++ b/Session/Storage/Handler/AbstractSessionHandler.php @@ -38,13 +38,13 @@ public function open(string $savePath, string $sessionName): bool return true; } - abstract protected function doRead(string $sessionId): string; + abstract protected function doRead(#[\SensitiveParameter] string $sessionId): string; - abstract protected function doWrite(string $sessionId, string $data): bool; + abstract protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool; - abstract protected function doDestroy(string $sessionId): bool; + abstract protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool; - public function validateId(string $sessionId): bool + public function validateId(#[\SensitiveParameter] string $sessionId): bool { $this->prefetchData = $this->read($sessionId); $this->prefetchId = $sessionId; @@ -52,7 +52,7 @@ public function validateId(string $sessionId): bool return '' !== $this->prefetchData; } - public function read(string $sessionId): string + public function read(#[\SensitiveParameter] string $sessionId): string { if (isset($this->prefetchId)) { $prefetchId = $this->prefetchId; @@ -72,7 +72,7 @@ public function read(string $sessionId): string return $data; } - public function write(string $sessionId, string $data): bool + public function write(#[\SensitiveParameter] string $sessionId, string $data): bool { // see https://github.com/igbinary/igbinary/issues/146 $this->igbinaryEmptyData ??= \function_exists('igbinary_serialize') ? igbinary_serialize([]) : ''; @@ -84,7 +84,7 @@ public function write(string $sessionId, string $data): bool return $this->doWrite($sessionId, $data); } - public function destroy(string $sessionId): bool + public function destroy(#[\SensitiveParameter] string $sessionId): bool { if (!headers_sent() && filter_var(\ini_get('session.use_cookies'), \FILTER_VALIDATE_BOOL)) { if (!isset($this->sessionName)) { diff --git a/Session/Storage/Handler/MarshallingSessionHandler.php b/Session/Storage/Handler/MarshallingSessionHandler.php index 9962fef3d..1567f5433 100644 --- a/Session/Storage/Handler/MarshallingSessionHandler.php +++ b/Session/Storage/Handler/MarshallingSessionHandler.php @@ -37,7 +37,7 @@ public function close(): bool return $this->handler->close(); } - public function destroy(string $sessionId): bool + public function destroy(#[\SensitiveParameter] string $sessionId): bool { return $this->handler->destroy($sessionId); } @@ -47,12 +47,12 @@ public function gc(int $maxlifetime): int|false return $this->handler->gc($maxlifetime); } - public function read(string $sessionId): string + public function read(#[\SensitiveParameter] string $sessionId): string { return $this->marshaller->unmarshall($this->handler->read($sessionId)); } - public function write(string $sessionId, string $data): bool + public function write(#[\SensitiveParameter] string $sessionId, string $data): bool { $failed = []; $marshalledData = $this->marshaller->marshall(['data' => $data], $failed); @@ -64,12 +64,12 @@ public function write(string $sessionId, string $data): bool return $this->handler->write($sessionId, $marshalledData['data']); } - public function validateId(string $sessionId): bool + public function validateId(#[\SensitiveParameter] string $sessionId): bool { return $this->handler->validateId($sessionId); } - public function updateTimestamp(string $sessionId, string $data): bool + public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { return $this->handler->updateTimestamp($sessionId, $data); } diff --git a/Session/Storage/Handler/MemcachedSessionHandler.php b/Session/Storage/Handler/MemcachedSessionHandler.php index 2bc29b459..91a023ddb 100644 --- a/Session/Storage/Handler/MemcachedSessionHandler.php +++ b/Session/Storage/Handler/MemcachedSessionHandler.php @@ -59,19 +59,19 @@ public function close(): bool return $this->memcached->quit(); } - protected function doRead(string $sessionId): string + protected function doRead(#[\SensitiveParameter] string $sessionId): string { return $this->memcached->get($this->prefix.$sessionId) ?: ''; } - public function updateTimestamp(string $sessionId, string $data): bool + public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { $this->memcached->touch($this->prefix.$sessionId, $this->getCompatibleTtl()); return true; } - protected function doWrite(string $sessionId, string $data): bool + protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool { return $this->memcached->set($this->prefix.$sessionId, $data, $this->getCompatibleTtl()); } @@ -89,7 +89,7 @@ private function getCompatibleTtl(): int return $ttl; } - protected function doDestroy(string $sessionId): bool + protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool { $result = $this->memcached->delete($this->prefix.$sessionId); diff --git a/Session/Storage/Handler/MigratingSessionHandler.php b/Session/Storage/Handler/MigratingSessionHandler.php index 1d4255236..8ed6a7b3f 100644 --- a/Session/Storage/Handler/MigratingSessionHandler.php +++ b/Session/Storage/Handler/MigratingSessionHandler.php @@ -22,15 +22,8 @@ */ class MigratingSessionHandler implements \SessionHandlerInterface, \SessionUpdateTimestampHandlerInterface { - /** - * @var \SessionHandlerInterface&\SessionUpdateTimestampHandlerInterface - */ - private \SessionHandlerInterface $currentHandler; - - /** - * @var \SessionHandlerInterface&\SessionUpdateTimestampHandlerInterface - */ - private \SessionHandlerInterface $writeOnlyHandler; + private \SessionHandlerInterface&\SessionUpdateTimestampHandlerInterface $currentHandler; + private \SessionHandlerInterface&\SessionUpdateTimestampHandlerInterface $writeOnlyHandler; public function __construct(\SessionHandlerInterface $currentHandler, \SessionHandlerInterface $writeOnlyHandler) { @@ -53,7 +46,7 @@ public function close(): bool return $result; } - public function destroy(string $sessionId): bool + public function destroy(#[\SensitiveParameter] string $sessionId): bool { $result = $this->currentHandler->destroy($sessionId); $this->writeOnlyHandler->destroy($sessionId); @@ -77,13 +70,13 @@ public function open(string $savePath, string $sessionName): bool return $result; } - public function read(string $sessionId): string + public function read(#[\SensitiveParameter] string $sessionId): string { // No reading from new handler until switch-over return $this->currentHandler->read($sessionId); } - public function write(string $sessionId, string $sessionData): bool + public function write(#[\SensitiveParameter] string $sessionId, string $sessionData): bool { $result = $this->currentHandler->write($sessionId, $sessionData); $this->writeOnlyHandler->write($sessionId, $sessionData); @@ -91,13 +84,13 @@ public function write(string $sessionId, string $sessionData): bool return $result; } - public function validateId(string $sessionId): bool + public function validateId(#[\SensitiveParameter] string $sessionId): bool { // No reading from new handler until switch-over return $this->currentHandler->validateId($sessionId); } - public function updateTimestamp(string $sessionId, string $sessionData): bool + public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $sessionData): bool { $result = $this->currentHandler->updateTimestamp($sessionId, $sessionData); $this->writeOnlyHandler->updateTimestamp($sessionId, $sessionData); diff --git a/Session/Storage/Handler/MongoDbSessionHandler.php b/Session/Storage/Handler/MongoDbSessionHandler.php index 63c609ae2..d5586030f 100644 --- a/Session/Storage/Handler/MongoDbSessionHandler.php +++ b/Session/Storage/Handler/MongoDbSessionHandler.php @@ -14,20 +14,22 @@ use MongoDB\BSON\Binary; use MongoDB\BSON\UTCDateTime; use MongoDB\Client; -use MongoDB\Collection; +use MongoDB\Driver\BulkWrite; +use MongoDB\Driver\Manager; +use MongoDB\Driver\Query; /** - * Session handler using the mongodb/mongodb package and MongoDB driver extension. + * Session handler using the MongoDB driver extension. * * @author Markus Bachmann + * @author Jérôme Tamarelle * - * @see https://packagist.org/packages/mongodb/mongodb * @see https://php.net/mongodb */ class MongoDbSessionHandler extends AbstractSessionHandler { - private Client $mongo; - private Collection $collection; + private Manager $manager; + private string $namespace; private array $options; private int|\Closure|null $ttl; @@ -62,13 +64,18 @@ class MongoDbSessionHandler extends AbstractSessionHandler * * @throws \InvalidArgumentException When "database" or "collection" not provided */ - public function __construct(Client $mongo, array $options) + public function __construct(Client|Manager $mongo, array $options) { if (!isset($options['database']) || !isset($options['collection'])) { throw new \InvalidArgumentException('You must provide the "database" and "collection" option for MongoDBSessionHandler.'); } - $this->mongo = $mongo; + if ($mongo instanceof Client) { + $mongo = $mongo->getManager(); + } + + $this->manager = $mongo; + $this->namespace = $options['database'].'.'.$options['collection']; $this->options = array_merge([ 'id_field' => '_id', @@ -84,79 +91,96 @@ public function close(): bool return true; } - protected function doDestroy(string $sessionId): bool + protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool { - $this->getCollection()->deleteOne([ - $this->options['id_field'] => $sessionId, - ]); + $write = new BulkWrite(); + $write->delete( + [$this->options['id_field'] => $sessionId], + ['limit' => 1] + ); + + $this->manager->executeBulkWrite($this->namespace, $write); return true; } public function gc(int $maxlifetime): int|false { - return $this->getCollection()->deleteMany([ - $this->options['expiry_field'] => ['$lt' => new UTCDateTime()], - ])->getDeletedCount(); + $write = new BulkWrite(); + $write->delete( + [$this->options['expiry_field'] => ['$lt' => $this->getUTCDateTime()]], + ); + $result = $this->manager->executeBulkWrite($this->namespace, $write); + + return $result->getDeletedCount() ?? false; } - protected function doWrite(string $sessionId, string $data): bool + protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool { $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime'); - $expiry = new UTCDateTime((time() + (int) $ttl) * 1000); + $expiry = $this->getUTCDateTime($ttl); $fields = [ - $this->options['time_field'] => new UTCDateTime(), + $this->options['time_field'] => $this->getUTCDateTime(), $this->options['expiry_field'] => $expiry, - $this->options['data_field'] => new Binary($data, Binary::TYPE_OLD_BINARY), + $this->options['data_field'] => new Binary($data, Binary::TYPE_GENERIC), ]; - $this->getCollection()->updateOne( + $write = new BulkWrite(); + $write->update( [$this->options['id_field'] => $sessionId], ['$set' => $fields], ['upsert' => true] ); + $this->manager->executeBulkWrite($this->namespace, $write); + return true; } - public function updateTimestamp(string $sessionId, string $data): bool + public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime'); - $expiry = new UTCDateTime((time() + (int) $ttl) * 1000); + $expiry = $this->getUTCDateTime($ttl); - $this->getCollection()->updateOne( + $write = new BulkWrite(); + $write->update( [$this->options['id_field'] => $sessionId], ['$set' => [ - $this->options['time_field'] => new UTCDateTime(), + $this->options['time_field'] => $this->getUTCDateTime(), $this->options['expiry_field'] => $expiry, - ]] + ]], + ['multi' => false], ); + $this->manager->executeBulkWrite($this->namespace, $write); + return true; } - protected function doRead(string $sessionId): string + protected function doRead(#[\SensitiveParameter] string $sessionId): string { - $dbData = $this->getCollection()->findOne([ + $cursor = $this->manager->executeQuery($this->namespace, new Query([ $this->options['id_field'] => $sessionId, - $this->options['expiry_field'] => ['$gte' => new UTCDateTime()], - ]); - - if (null === $dbData) { - return ''; + $this->options['expiry_field'] => ['$gte' => $this->getUTCDateTime()], + ], [ + 'projection' => [ + '_id' => false, + $this->options['data_field'] => true, + ], + 'limit' => 1, + ])); + + foreach ($cursor as $document) { + return (string) $document->{$this->options['data_field']} ?? ''; } - return $dbData[$this->options['data_field']]->getData(); - } - - private function getCollection(): Collection - { - return $this->collection ??= $this->mongo->selectCollection($this->options['database'], $this->options['collection']); + // Not found + return ''; } - protected function getMongo(): Client + private function getUTCDateTime(int $additionalSeconds = 0): UTCDateTime { - return $this->mongo; + return new UTCDateTime((time() + $additionalSeconds) * 1000); } } diff --git a/Session/Storage/Handler/NativeFileSessionHandler.php b/Session/Storage/Handler/NativeFileSessionHandler.php index f6e73f9e6..f8c6151a4 100644 --- a/Session/Storage/Handler/NativeFileSessionHandler.php +++ b/Session/Storage/Handler/NativeFileSessionHandler.php @@ -28,7 +28,7 @@ class NativeFileSessionHandler extends \SessionHandler * @throws \InvalidArgumentException On invalid $savePath * @throws \RuntimeException When failing to create the save directory */ - public function __construct(string $savePath = null) + public function __construct(?string $savePath = null) { $baseDir = $savePath ??= \ini_get('session.save_path'); diff --git a/Session/Storage/Handler/NullSessionHandler.php b/Session/Storage/Handler/NullSessionHandler.php index 790ac2fed..a77185e2e 100644 --- a/Session/Storage/Handler/NullSessionHandler.php +++ b/Session/Storage/Handler/NullSessionHandler.php @@ -23,27 +23,27 @@ public function close(): bool return true; } - public function validateId(string $sessionId): bool + public function validateId(#[\SensitiveParameter] string $sessionId): bool { return true; } - protected function doRead(string $sessionId): string + protected function doRead(#[\SensitiveParameter] string $sessionId): string { return ''; } - public function updateTimestamp(string $sessionId, string $data): bool + public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { return true; } - protected function doWrite(string $sessionId, string $data): bool + protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool { return true; } - protected function doDestroy(string $sessionId): bool + protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool { return true; } diff --git a/Session/Storage/Handler/PdoSessionHandler.php b/Session/Storage/Handler/PdoSessionHandler.php index 06429b4ba..9cee76ddf 100644 --- a/Session/Storage/Handler/PdoSessionHandler.php +++ b/Session/Storage/Handler/PdoSessionHandler.php @@ -11,6 +11,9 @@ namespace Symfony\Component\HttpFoundation\Session\Storage\Handler; +use Doctrine\DBAL\Schema\Schema; +use Doctrine\DBAL\Types\Types; + /** * Session handler using a PDO connection to read and write data. * @@ -87,12 +90,12 @@ class PdoSessionHandler extends AbstractSessionHandler /** * Username when lazy-connect. */ - private string $username = ''; + private ?string $username = null; /** * Password when lazy-connect. */ - private string $password = ''; + private ?string $password = null; /** * Connection options when lazy-connect. @@ -148,7 +151,7 @@ class PdoSessionHandler extends AbstractSessionHandler * * @throws \InvalidArgumentException When PDO error mode is not PDO::ERRMODE_EXCEPTION */ - public function __construct(\PDO|string $pdoOrDsn = null, array $options = []) + public function __construct(#[\SensitiveParameter] \PDO|string|null $pdoOrDsn = null, #[\SensitiveParameter] array $options = []) { if ($pdoOrDsn instanceof \PDO) { if (\PDO::ERRMODE_EXCEPTION !== $pdoOrDsn->getAttribute(\PDO::ATTR_ERRMODE)) { @@ -175,6 +178,56 @@ public function __construct(\PDO|string $pdoOrDsn = null, array $options = []) $this->ttl = $options['ttl'] ?? null; } + /** + * Adds the Table to the Schema if it doesn't exist. + */ + public function configureSchema(Schema $schema, ?\Closure $isSameDatabase = null): void + { + if ($schema->hasTable($this->table) || ($isSameDatabase && !$isSameDatabase($this->getConnection()->exec(...)))) { + return; + } + + $table = $schema->createTable($this->table); + switch ($this->driver) { + case 'mysql': + $table->addColumn($this->idCol, Types::BINARY)->setLength(128)->setNotnull(true); + $table->addColumn($this->dataCol, Types::BLOB)->setNotnull(true); + $table->addColumn($this->lifetimeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true); + $table->addColumn($this->timeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true); + $table->addOption('collate', 'utf8mb4_bin'); + $table->addOption('engine', 'InnoDB'); + break; + case 'sqlite': + $table->addColumn($this->idCol, Types::TEXT)->setNotnull(true); + $table->addColumn($this->dataCol, Types::BLOB)->setNotnull(true); + $table->addColumn($this->lifetimeCol, Types::INTEGER)->setNotnull(true); + $table->addColumn($this->timeCol, Types::INTEGER)->setNotnull(true); + break; + case 'pgsql': + $table->addColumn($this->idCol, Types::STRING)->setLength(128)->setNotnull(true); + $table->addColumn($this->dataCol, Types::BINARY)->setNotnull(true); + $table->addColumn($this->lifetimeCol, Types::INTEGER)->setNotnull(true); + $table->addColumn($this->timeCol, Types::INTEGER)->setNotnull(true); + break; + case 'oci': + $table->addColumn($this->idCol, Types::STRING)->setLength(128)->setNotnull(true); + $table->addColumn($this->dataCol, Types::BLOB)->setNotnull(true); + $table->addColumn($this->lifetimeCol, Types::INTEGER)->setNotnull(true); + $table->addColumn($this->timeCol, Types::INTEGER)->setNotnull(true); + break; + case 'sqlsrv': + $table->addColumn($this->idCol, Types::TEXT)->setLength(128)->setNotnull(true); + $table->addColumn($this->dataCol, Types::BLOB)->setNotnull(true); + $table->addColumn($this->lifetimeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true); + $table->addColumn($this->timeCol, Types::INTEGER)->setUnsigned(true)->setNotnull(true); + break; + default: + throw new \DomainException(sprintf('Creating the session table is currently not implemented for PDO driver "%s".', $this->driver)); + } + $table->setPrimaryKey([$this->idCol]); + $table->addIndex([$this->lifetimeCol], $this->lifetimeCol.'_idx'); + } + /** * Creates the table to store sessions which can be called once for setup. * @@ -183,6 +236,8 @@ public function __construct(\PDO|string $pdoOrDsn = null, array $options = []) * saved in a BLOB. One could also use a shorter inlined varbinary column * if one was sure the data fits into it. * + * @return void + * * @throws \PDOException When the table already exists * @throws \DomainException When an unsupported PDO driver is used */ @@ -207,7 +262,7 @@ public function createTable() try { $this->pdo->exec($sql); - $this->pdo->exec("CREATE INDEX expiry ON $this->table ($this->lifetimeCol)"); + $this->pdo->exec("CREATE INDEX {$this->lifetimeCol}_idx ON $this->table ($this->lifetimeCol)"); } catch (\PDOException $e) { $this->rollback(); @@ -236,7 +291,7 @@ public function open(string $savePath, string $sessionName): bool return parent::open($savePath, $sessionName); } - public function read(string $sessionId): string + public function read(#[\SensitiveParameter] string $sessionId): string { try { return parent::read($sessionId); @@ -256,7 +311,7 @@ public function gc(int $maxlifetime): int|false return 0; } - protected function doDestroy(string $sessionId): bool + protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool { // delete the record associated with this id $sql = "DELETE FROM $this->table WHERE $this->idCol = :id"; @@ -274,7 +329,7 @@ protected function doDestroy(string $sessionId): bool return true; } - protected function doWrite(string $sessionId, string $data): bool + protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool { $maxlifetime = (int) (($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime')); @@ -317,7 +372,7 @@ protected function doWrite(string $sessionId, string $data): bool return true; } - public function updateTimestamp(string $sessionId, string $data): bool + public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { $expiry = time() + (int) (($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime')); @@ -325,8 +380,8 @@ public function updateTimestamp(string $sessionId, string $data): bool $updateStmt = $this->pdo->prepare( "UPDATE $this->table SET $this->lifetimeCol = :expiry, $this->timeCol = :time WHERE $this->idCol = :id" ); - $updateStmt->bindParam(':id', $sessionId, \PDO::PARAM_STR); - $updateStmt->bindParam(':expiry', $expiry, \PDO::PARAM_INT); + $updateStmt->bindValue(':id', $sessionId, \PDO::PARAM_STR); + $updateStmt->bindValue(':expiry', $expiry, \PDO::PARAM_INT); $updateStmt->bindValue(':time', time(), \PDO::PARAM_INT); $updateStmt->execute(); } catch (\PDOException $e) { @@ -366,7 +421,7 @@ public function close(): bool /** * Lazy-connects to the database. */ - private function connect(string $dsn): void + private function connect(#[\SensitiveParameter] string $dsn): void { $this->pdo = new \PDO($dsn, $this->username, $this->password, $this->connectionOptions); $this->pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); @@ -378,7 +433,7 @@ private function connect(string $dsn): void * * @todo implement missing support for oci DSN (which look totally different from other PDO ones) */ - private function buildDsnFromUrl(string $dsnOrUrl): string + private function buildDsnFromUrl(#[\SensitiveParameter] string $dsnOrUrl): string { // (pdo_)?sqlite3?:///... => (pdo_)?sqlite3?://localhost/... or else the URL will be invalid $url = preg_replace('#^((?:pdo_)?sqlite3?):///#', '$1://localhost/', $dsnOrUrl); @@ -561,7 +616,7 @@ private function rollback(): void * We need to make sure we do not return session data that is already considered garbage according * to the session.gc_maxlifetime setting because gc() is called after read() and only sometimes. */ - protected function doRead(string $sessionId): string + protected function doRead(#[\SensitiveParameter] string $sessionId): string { if (self::LOCK_ADVISORY === $this->lockMode) { $this->unlockStatements[] = $this->doAdvisoryLock($sessionId); @@ -632,7 +687,7 @@ protected function doRead(string $sessionId): string * - for oci using DBMS_LOCK.REQUEST * - for sqlsrv using sp_getapplock with LockOwner = Session */ - private function doAdvisoryLock(string $sessionId): \PDOStatement + private function doAdvisoryLock(#[\SensitiveParameter] string $sessionId): \PDOStatement { switch ($this->driver) { case 'mysql': @@ -731,7 +786,7 @@ private function getSelectSql(): string /** * Returns an insert statement supported by the database for writing session data. */ - private function getInsertStatement(string $sessionId, string $sessionData, int $maxlifetime): \PDOStatement + private function getInsertStatement(#[\SensitiveParameter] string $sessionId, string $sessionData, int $maxlifetime): \PDOStatement { switch ($this->driver) { case 'oci': @@ -758,7 +813,7 @@ private function getInsertStatement(string $sessionId, string $sessionData, int /** * Returns an update statement supported by the database for writing session data. */ - private function getUpdateStatement(string $sessionId, string $sessionData, int $maxlifetime): \PDOStatement + private function getUpdateStatement(#[\SensitiveParameter] string $sessionId, string $sessionData, int $maxlifetime): \PDOStatement { switch ($this->driver) { case 'oci': @@ -785,7 +840,7 @@ private function getUpdateStatement(string $sessionId, string $sessionData, int /** * Returns a merge/upsert (i.e. insert or update) statement when supported by the database for writing session data. */ - private function getMergeStatement(string $sessionId, string $data, int $maxlifetime): ?\PDOStatement + private function getMergeStatement(#[\SensitiveParameter] string $sessionId, string $data, int $maxlifetime): ?\PDOStatement { switch (true) { case 'mysql' === $this->driver: diff --git a/Session/Storage/Handler/RedisSessionHandler.php b/Session/Storage/Handler/RedisSessionHandler.php index 38f488644..b696eee4b 100644 --- a/Session/Storage/Handler/RedisSessionHandler.php +++ b/Session/Storage/Handler/RedisSessionHandler.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpFoundation\Session\Storage\Handler; use Predis\Response\ErrorInterface; +use Relay\Relay; /** * Redis based session storage handler based on the Redis class @@ -39,7 +40,7 @@ class RedisSessionHandler extends AbstractSessionHandler * @throws \InvalidArgumentException When unsupported client or options are passed */ public function __construct( - private \Redis|\RedisArray|\RedisCluster|\Predis\ClientInterface $redis, + private \Redis|Relay|\RedisArray|\RedisCluster|\Predis\ClientInterface $redis, array $options = [], ) { if ($diff = array_diff(array_keys($options), ['prefix', 'ttl'])) { @@ -50,12 +51,12 @@ public function __construct( $this->ttl = $options['ttl'] ?? null; } - protected function doRead(string $sessionId): string + protected function doRead(#[\SensitiveParameter] string $sessionId): string { return $this->redis->get($this->prefix.$sessionId) ?: ''; } - protected function doWrite(string $sessionId, string $data): bool + protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool { $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime'); $result = $this->redis->setEx($this->prefix.$sessionId, (int) $ttl, $data); @@ -63,7 +64,7 @@ protected function doWrite(string $sessionId, string $data): bool return $result && !$result instanceof ErrorInterface; } - protected function doDestroy(string $sessionId): bool + protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool { static $unlink = true; @@ -93,7 +94,7 @@ public function gc(int $maxlifetime): int|false return 0; } - public function updateTimestamp(string $sessionId, string $data): bool + public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { $ttl = ($this->ttl instanceof \Closure ? ($this->ttl)() : $this->ttl) ?? \ini_get('session.gc_maxlifetime'); diff --git a/Session/Storage/Handler/SessionHandlerFactory.php b/Session/Storage/Handler/SessionHandlerFactory.php index e390c8fee..ff5b70d81 100644 --- a/Session/Storage/Handler/SessionHandlerFactory.php +++ b/Session/Storage/Handler/SessionHandlerFactory.php @@ -11,7 +11,11 @@ namespace Symfony\Component\HttpFoundation\Session\Storage\Handler; +use Doctrine\DBAL\Configuration; use Doctrine\DBAL\DriverManager; +use Doctrine\DBAL\Schema\DefaultSchemaManagerFactory; +use Doctrine\DBAL\Tools\DsnParser; +use Relay\Relay; use Symfony\Component\Cache\Adapter\AbstractAdapter; /** @@ -32,6 +36,7 @@ public static function createHandler(object|string $connection, array $options = switch (true) { case $connection instanceof \Redis: + case $connection instanceof Relay: case $connection instanceof \RedisArray: case $connection instanceof \RedisCluster: case $connection instanceof \Predis\ClientInterface: @@ -54,7 +59,7 @@ public static function createHandler(object|string $connection, array $options = case str_starts_with($connection, 'rediss:'): case str_starts_with($connection, 'memcached:'): if (!class_exists(AbstractAdapter::class)) { - throw new \InvalidArgumentException(sprintf('Unsupported DSN "%s". Try running "composer require symfony/cache".', $connection)); + throw new \InvalidArgumentException('Unsupported Redis or Memcached DSN. Try running "composer require symfony/cache".'); } $handlerClass = str_starts_with($connection, 'memcached:') ? MemcachedSessionHandler::class : RedisSessionHandler::class; $connection = AbstractAdapter::createConnection($connection, ['lazy' => true]); @@ -63,9 +68,18 @@ public static function createHandler(object|string $connection, array $options = case str_starts_with($connection, 'pdo_oci://'): if (!class_exists(DriverManager::class)) { - throw new \InvalidArgumentException(sprintf('Unsupported DSN "%s". Try running "composer require doctrine/dbal".', $connection)); + throw new \InvalidArgumentException('Unsupported PDO OCI DSN. Try running "composer require doctrine/dbal".'); } - $connection = DriverManager::getConnection(['url' => $connection])->getWrappedConnection(); + $connection[3] = '-'; + $params = class_exists(DsnParser::class) ? (new DsnParser())->parse($connection) : ['url' => $connection]; + $config = new Configuration(); + if (class_exists(DefaultSchemaManagerFactory::class)) { + $config->setSchemaManagerFactory(new DefaultSchemaManagerFactory()); + } + + $connection = DriverManager::getConnection($params, $config); + // The condition should be removed once support for DBAL <3.3 is dropped + $connection = method_exists($connection, 'getNativeConnection') ? $connection->getNativeConnection() : $connection->getWrappedConnection(); // no break; case str_starts_with($connection, 'mssql://'): diff --git a/Session/Storage/Handler/StrictSessionHandler.php b/Session/Storage/Handler/StrictSessionHandler.php index 1a1636481..1f8668744 100644 --- a/Session/Storage/Handler/StrictSessionHandler.php +++ b/Session/Storage/Handler/StrictSessionHandler.php @@ -47,22 +47,22 @@ public function open(string $savePath, string $sessionName): bool return $this->handler->open($savePath, $sessionName); } - protected function doRead(string $sessionId): string + protected function doRead(#[\SensitiveParameter] string $sessionId): string { return $this->handler->read($sessionId); } - public function updateTimestamp(string $sessionId, string $data): bool + public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { return $this->write($sessionId, $data); } - protected function doWrite(string $sessionId, string $data): bool + protected function doWrite(#[\SensitiveParameter] string $sessionId, string $data): bool { return $this->handler->write($sessionId, $data); } - public function destroy(string $sessionId): bool + public function destroy(#[\SensitiveParameter] string $sessionId): bool { $this->doDestroy = true; $destroyed = parent::destroy($sessionId); @@ -70,7 +70,7 @@ public function destroy(string $sessionId): bool return $this->doDestroy ? $this->doDestroy($sessionId) : $destroyed; } - protected function doDestroy(string $sessionId): bool + protected function doDestroy(#[\SensitiveParameter] string $sessionId): bool { $this->doDestroy = false; diff --git a/Session/Storage/MetadataBag.php b/Session/Storage/MetadataBag.php index 2c77f9db6..5bb4cfbc7 100644 --- a/Session/Storage/MetadataBag.php +++ b/Session/Storage/MetadataBag.php @@ -51,6 +51,9 @@ public function __construct(string $storageKey = '_sf2_meta', int $updateThresho $this->updateThreshold = $updateThreshold; } + /** + * @return void + */ public function initialize(array &$array) { $this->meta = &$array; @@ -82,8 +85,10 @@ public function getLifetime(): int * will leave the system settings unchanged, 0 sets the cookie * to expire with browser session. Time is in seconds, and is * not a Unix timestamp. + * + * @return void */ - public function stampNew(int $lifetime = null) + public function stampNew(?int $lifetime = null) { $this->stampCreated($lifetime); } @@ -126,13 +131,15 @@ public function getName(): string /** * Sets name. + * + * @return void */ public function setName(string $name) { $this->name = $name; } - private function stampCreated(int $lifetime = null): void + private function stampCreated(?int $lifetime = null): void { $timeStamp = time(); $this->meta[self::CREATED] = $this->meta[self::UPDATED] = $this->lastUsed = $timeStamp; diff --git a/Session/Storage/MockArraySessionStorage.php b/Session/Storage/MockArraySessionStorage.php index 67fa0f95e..f02793d3e 100644 --- a/Session/Storage/MockArraySessionStorage.php +++ b/Session/Storage/MockArraySessionStorage.php @@ -62,12 +62,15 @@ class MockArraySessionStorage implements SessionStorageInterface */ protected $bags = []; - public function __construct(string $name = 'MOCKSESSID', MetadataBag $metaBag = null) + public function __construct(string $name = 'MOCKSESSID', ?MetadataBag $metaBag = null) { $this->name = $name; $this->setMetadataBag($metaBag); } + /** + * @return void + */ public function setSessionData(array $array) { $this->data = $array; @@ -88,7 +91,7 @@ public function start(): bool return true; } - public function regenerate(bool $destroy = false, int $lifetime = null): bool + public function regenerate(bool $destroy = false, ?int $lifetime = null): bool { if (!$this->started) { $this->start(); @@ -105,6 +108,9 @@ public function getId(): string return $this->id; } + /** + * @return void + */ public function setId(string $id) { if ($this->started) { @@ -119,11 +125,17 @@ public function getName(): string return $this->name; } + /** + * @return void + */ public function setName(string $name) { $this->name = $name; } + /** + * @return void + */ public function save() { if (!$this->started || $this->closed) { @@ -134,6 +146,9 @@ public function save() $this->started = false; } + /** + * @return void + */ public function clear() { // clear out the bags @@ -148,6 +163,9 @@ public function clear() $this->loadSession(); } + /** + * @return void + */ public function registerBag(SessionBagInterface $bag) { $this->bags[$bag->getName()] = $bag; @@ -171,7 +189,10 @@ public function isStarted(): bool return $this->started; } - public function setMetadataBag(MetadataBag $bag = null) + /** + * @return void + */ + public function setMetadataBag(?MetadataBag $bag = null) { if (1 > \func_num_args()) { trigger_deprecation('symfony/http-foundation', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); @@ -195,9 +216,12 @@ public function getMetadataBag(): MetadataBag */ protected function generateId(): string { - return hash('sha256', uniqid('ss_mock_', true)); + return bin2hex(random_bytes(16)); } + /** + * @return void + */ protected function loadSession() { $bags = array_merge($this->bags, [$this->metadataBag]); diff --git a/Session/Storage/MockFileSessionStorage.php b/Session/Storage/MockFileSessionStorage.php index 28771ad54..ef6d9d8f8 100644 --- a/Session/Storage/MockFileSessionStorage.php +++ b/Session/Storage/MockFileSessionStorage.php @@ -30,7 +30,7 @@ class MockFileSessionStorage extends MockArraySessionStorage /** * @param string|null $savePath Path of directory to save session files */ - public function __construct(string $savePath = null, string $name = 'MOCKSESSID', MetadataBag $metaBag = null) + public function __construct(?string $savePath = null, string $name = 'MOCKSESSID', ?MetadataBag $metaBag = null) { $savePath ??= sys_get_temp_dir(); @@ -60,7 +60,7 @@ public function start(): bool return true; } - public function regenerate(bool $destroy = false, int $lifetime = null): bool + public function regenerate(bool $destroy = false, ?int $lifetime = null): bool { if (!$this->started) { $this->start(); @@ -73,6 +73,9 @@ public function regenerate(bool $destroy = false, int $lifetime = null): bool return parent::regenerate($destroy, $lifetime); } + /** + * @return void + */ public function save() { if (!$this->started) { diff --git a/Session/Storage/MockFileSessionStorageFactory.php b/Session/Storage/MockFileSessionStorageFactory.php index 8ecf943dc..6727cf14f 100644 --- a/Session/Storage/MockFileSessionStorageFactory.php +++ b/Session/Storage/MockFileSessionStorageFactory.php @@ -28,7 +28,7 @@ class MockFileSessionStorageFactory implements SessionStorageFactoryInterface /** * @see MockFileSessionStorage constructor. */ - public function __construct(string $savePath = null, string $name = 'MOCKSESSID', MetadataBag $metaBag = null) + public function __construct(?string $savePath = null, string $name = 'MOCKSESSID', ?MetadataBag $metaBag = null) { $this->savePath = $savePath; $this->name = $name; diff --git a/Session/Storage/NativeSessionStorage.php b/Session/Storage/NativeSessionStorage.php index 8b8947882..f63de5740 100644 --- a/Session/Storage/NativeSessionStorage.php +++ b/Session/Storage/NativeSessionStorage.php @@ -89,7 +89,7 @@ class NativeSessionStorage implements SessionStorageInterface * trans_sid_hosts, $_SERVER['HTTP_HOST'] * trans_sid_tags, "a=href,area=href,frame=src,form=" */ - public function __construct(array $options = [], AbstractProxy|\SessionHandlerInterface $handler = null, MetadataBag $metaBag = null) + public function __construct(array $options = [], AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null) { if (!\extension_loaded('session')) { throw new \LogicException('PHP extension "session" is required.'); @@ -183,6 +183,9 @@ public function getId(): string return $this->saveHandler->getId(); } + /** + * @return void + */ public function setId(string $id) { $this->saveHandler->setId($id); @@ -193,12 +196,15 @@ public function getName(): string return $this->saveHandler->getName(); } + /** + * @return void + */ public function setName(string $name) { $this->saveHandler->setName($name); } - public function regenerate(bool $destroy = false, int $lifetime = null): bool + public function regenerate(bool $destroy = false, ?int $lifetime = null): bool { // Cannot regenerate the session ID for non-active sessions. if (\PHP_SESSION_ACTIVE !== session_status()) { @@ -222,6 +228,9 @@ public function regenerate(bool $destroy = false, int $lifetime = null): bool return session_regenerate_id($destroy); } + /** + * @return void + */ public function save() { // Store a copy so we can restore the bags in case the session was not left empty @@ -261,6 +270,9 @@ public function save() $this->started = false; } + /** + * @return void + */ public function clear() { // clear out the bags @@ -275,6 +287,9 @@ public function clear() $this->loadSession(); } + /** + * @return void + */ public function registerBag(SessionBagInterface $bag) { if ($this->started) { @@ -299,7 +314,10 @@ public function getBag(string $name): SessionBagInterface return $this->bags[$name]; } - public function setMetadataBag(MetadataBag $metaBag = null) + /** + * @return void + */ + public function setMetadataBag(?MetadataBag $metaBag = null) { if (1 > \func_num_args()) { trigger_deprecation('symfony/http-foundation', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); @@ -329,6 +347,8 @@ public function isStarted(): bool * @param array $options Session ini directives [key => value] * * @see https://php.net/session.configuration + * + * @return void */ public function setOptions(array $options) { @@ -372,9 +392,11 @@ public function setOptions(array $options) * @see https://php.net/sessionhandlerinterface * @see https://php.net/sessionhandler * + * @return void + * * @throws \InvalidArgumentException */ - public function setSaveHandler(AbstractProxy|\SessionHandlerInterface $saveHandler = null) + public function setSaveHandler(AbstractProxy|\SessionHandlerInterface|null $saveHandler = null) { if (1 > \func_num_args()) { trigger_deprecation('symfony/http-foundation', '6.2', 'Calling "%s()" without any arguments is deprecated, pass null explicitly instead.', __METHOD__); @@ -404,8 +426,10 @@ public function setSaveHandler(AbstractProxy|\SessionHandlerInterface $saveHandl * are set to (either PHP's internal, or a custom save handler set with session_set_save_handler()). * PHP takes the return value from the read() handler, unserializes it * and populates $_SESSION with the result automatically. + * + * @return void */ - protected function loadSession(array &$session = null) + protected function loadSession(?array &$session = null) { if (null === $session) { $session = &$_SESSION; diff --git a/Session/Storage/NativeSessionStorageFactory.php b/Session/Storage/NativeSessionStorageFactory.php index 08901284c..6463a4c1b 100644 --- a/Session/Storage/NativeSessionStorageFactory.php +++ b/Session/Storage/NativeSessionStorageFactory.php @@ -30,7 +30,7 @@ class NativeSessionStorageFactory implements SessionStorageFactoryInterface /** * @see NativeSessionStorage constructor. */ - public function __construct(array $options = [], AbstractProxy|\SessionHandlerInterface $handler = null, MetadataBag $metaBag = null, bool $secure = false) + public function __construct(array $options = [], AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null, bool $secure = false) { $this->options = $options; $this->handler = $handler; diff --git a/Session/Storage/PhpBridgeSessionStorage.php b/Session/Storage/PhpBridgeSessionStorage.php index eed748f81..4fb26d2a9 100644 --- a/Session/Storage/PhpBridgeSessionStorage.php +++ b/Session/Storage/PhpBridgeSessionStorage.php @@ -20,7 +20,7 @@ */ class PhpBridgeSessionStorage extends NativeSessionStorage { - public function __construct(AbstractProxy|\SessionHandlerInterface $handler = null, MetadataBag $metaBag = null) + public function __construct(AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null) { if (!\extension_loaded('session')) { throw new \LogicException('PHP extension "session" is required.'); @@ -41,6 +41,9 @@ public function start(): bool return true; } + /** + * @return void + */ public function clear() { // clear out the bags and nothing else that may be set diff --git a/Session/Storage/PhpBridgeSessionStorageFactory.php b/Session/Storage/PhpBridgeSessionStorageFactory.php index 5cc738024..aa4f800d3 100644 --- a/Session/Storage/PhpBridgeSessionStorageFactory.php +++ b/Session/Storage/PhpBridgeSessionStorageFactory.php @@ -26,7 +26,7 @@ class PhpBridgeSessionStorageFactory implements SessionStorageFactoryInterface private ?MetadataBag $metaBag; private bool $secure; - public function __construct(AbstractProxy|\SessionHandlerInterface $handler = null, MetadataBag $metaBag = null, bool $secure = false) + public function __construct(AbstractProxy|\SessionHandlerInterface|null $handler = null, ?MetadataBag $metaBag = null, bool $secure = false) { $this->handler = $handler; $this->metaBag = $metaBag; diff --git a/Session/Storage/Proxy/AbstractProxy.php b/Session/Storage/Proxy/AbstractProxy.php index 1845ee2c9..2fcd06b10 100644 --- a/Session/Storage/Proxy/AbstractProxy.php +++ b/Session/Storage/Proxy/AbstractProxy.php @@ -71,6 +71,8 @@ public function getId(): string /** * Sets the session ID. * + * @return void + * * @throws \LogicException */ public function setId(string $id) @@ -93,6 +95,8 @@ public function getName(): string /** * Sets the session name. * + * @return void + * * @throws \LogicException */ public function setName(string $name) diff --git a/Session/Storage/Proxy/SessionHandlerProxy.php b/Session/Storage/Proxy/SessionHandlerProxy.php index c292e58f0..7bf3f9ff1 100644 --- a/Session/Storage/Proxy/SessionHandlerProxy.php +++ b/Session/Storage/Proxy/SessionHandlerProxy.php @@ -44,17 +44,17 @@ public function close(): bool return $this->handler->close(); } - public function read(string $sessionId): string|false + public function read(#[\SensitiveParameter] string $sessionId): string|false { return $this->handler->read($sessionId); } - public function write(string $sessionId, string $data): bool + public function write(#[\SensitiveParameter] string $sessionId, string $data): bool { return $this->handler->write($sessionId, $data); } - public function destroy(string $sessionId): bool + public function destroy(#[\SensitiveParameter] string $sessionId): bool { return $this->handler->destroy($sessionId); } @@ -64,12 +64,12 @@ public function gc(int $maxlifetime): int|false return $this->handler->gc($maxlifetime); } - public function validateId(string $sessionId): bool + public function validateId(#[\SensitiveParameter] string $sessionId): bool { return !$this->handler instanceof \SessionUpdateTimestampHandlerInterface || $this->handler->validateId($sessionId); } - public function updateTimestamp(string $sessionId, string $data): bool + public function updateTimestamp(#[\SensitiveParameter] string $sessionId, string $data): bool { return $this->handler instanceof \SessionUpdateTimestampHandlerInterface ? $this->handler->updateTimestamp($sessionId, $data) : $this->write($sessionId, $data); } diff --git a/Session/Storage/SessionStorageInterface.php b/Session/Storage/SessionStorageInterface.php index 8bd62a43a..7865135b0 100644 --- a/Session/Storage/SessionStorageInterface.php +++ b/Session/Storage/SessionStorageInterface.php @@ -40,6 +40,8 @@ public function getId(): string; /** * Sets the session ID. + * + * @return void */ public function setId(string $id); @@ -50,6 +52,8 @@ public function getName(): string; /** * Sets the session name. + * + * @return void */ public function setName(string $name); @@ -80,7 +84,7 @@ public function setName(string $name); * * @throws \RuntimeException If an error occurs while regenerating this storage */ - public function regenerate(bool $destroy = false, int $lifetime = null): bool; + public function regenerate(bool $destroy = false, ?int $lifetime = null): bool; /** * Force the session to be saved and closed. @@ -90,6 +94,8 @@ public function regenerate(bool $destroy = false, int $lifetime = null): bool; * a real PHP session would interfere with testing, in which case * it should actually persist the session data if required. * + * @return void + * * @throws \RuntimeException if the session is saved without being started, or if the session * is already closed */ @@ -97,6 +103,8 @@ public function save(); /** * Clear all session data in memory. + * + * @return void */ public function clear(); @@ -109,6 +117,8 @@ public function getBag(string $name): SessionBagInterface; /** * Registers a SessionBagInterface for use. + * + * @return void */ public function registerBag(SessionBagInterface $bag); diff --git a/StreamedJsonResponse.php b/StreamedJsonResponse.php new file mode 100644 index 000000000..5b20ce910 --- /dev/null +++ b/StreamedJsonResponse.php @@ -0,0 +1,162 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation; + +/** + * StreamedJsonResponse represents a streamed HTTP response for JSON. + * + * A StreamedJsonResponse uses a structure and generics to create an + * efficient resource-saving JSON response. + * + * It is recommended to use flush() function after a specific number of items to directly stream the data. + * + * @see flush() + * + * @author Alexander Schranz + * + * Example usage: + * + * function loadArticles(): \Generator + * // some streamed loading + * yield ['title' => 'Article 1']; + * yield ['title' => 'Article 2']; + * yield ['title' => 'Article 3']; + * // recommended to use flush() after every specific number of items + * }), + * + * $response = new StreamedJsonResponse( + * // json structure with generators in which will be streamed + * [ + * '_embedded' => [ + * 'articles' => loadArticles(), // any generator which you want to stream as list of data + * ], + * ], + * ); + */ +class StreamedJsonResponse extends StreamedResponse +{ + private const PLACEHOLDER = '__symfony_json__'; + + /** + * @param mixed[] $data JSON Data containing PHP generators which will be streamed as list of data or a Generator + * @param int $status The HTTP status code (200 "OK" by default) + * @param array $headers An array of HTTP headers + * @param int $encodingOptions Flags for the json_encode() function + */ + public function __construct( + private readonly iterable $data, + int $status = 200, + array $headers = [], + private int $encodingOptions = JsonResponse::DEFAULT_ENCODING_OPTIONS, + ) { + parent::__construct($this->stream(...), $status, $headers); + + if (!$this->headers->get('Content-Type')) { + $this->headers->set('Content-Type', 'application/json'); + } + } + + private function stream(): void + { + $jsonEncodingOptions = \JSON_THROW_ON_ERROR | $this->encodingOptions; + $keyEncodingOptions = $jsonEncodingOptions & ~\JSON_NUMERIC_CHECK; + + $this->streamData($this->data, $jsonEncodingOptions, $keyEncodingOptions); + } + + private function streamData(mixed $data, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + if (\is_array($data)) { + $this->streamArray($data, $jsonEncodingOptions, $keyEncodingOptions); + + return; + } + + if (is_iterable($data) && !$data instanceof \JsonSerializable) { + $this->streamIterable($data, $jsonEncodingOptions, $keyEncodingOptions); + + return; + } + + echo json_encode($data, $jsonEncodingOptions); + } + + private function streamArray(array $data, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + $generators = []; + + array_walk_recursive($data, function (&$item, $key) use (&$generators) { + if (self::PLACEHOLDER === $key) { + // if the placeholder is already in the structure it should be replaced with a new one that explode + // works like expected for the structure + $generators[] = $key; + } + + // generators should be used but for better DX all kind of Traversable and objects are supported + if (\is_object($item)) { + $generators[] = $item; + $item = self::PLACEHOLDER; + } elseif (self::PLACEHOLDER === $item) { + // if the placeholder is already in the structure it should be replaced with a new one that explode + // works like expected for the structure + $generators[] = $item; + } + }); + + $jsonParts = explode('"'.self::PLACEHOLDER.'"', json_encode($data, $jsonEncodingOptions)); + + foreach ($generators as $index => $generator) { + // send first and between parts of the structure + echo $jsonParts[$index]; + + $this->streamData($generator, $jsonEncodingOptions, $keyEncodingOptions); + } + + // send last part of the structure + echo $jsonParts[array_key_last($jsonParts)]; + } + + private function streamIterable(iterable $iterable, int $jsonEncodingOptions, int $keyEncodingOptions): void + { + $isFirstItem = true; + $startTag = '['; + + foreach ($iterable as $key => $item) { + if ($isFirstItem) { + $isFirstItem = false; + // depending on the first elements key the generator is detected as a list or map + // we can not check for a whole list or map because that would hurt the performance + // of the streamed response which is the main goal of this response class + if (0 !== $key) { + $startTag = '{'; + } + + echo $startTag; + } else { + // if not first element of the generic, a separator is required between the elements + echo ','; + } + + if ('{' === $startTag) { + echo json_encode((string) $key, $keyEncodingOptions).':'; + } + + $this->streamData($item, $jsonEncodingOptions, $keyEncodingOptions); + } + + if ($isFirstItem) { // indicates that the generator was empty + echo '['; + } + + echo '[' === $startTag ? ']' : '}'; + } +} diff --git a/StreamedResponse.php b/StreamedResponse.php index 0bddcdc9b..0ab88e098 100644 --- a/StreamedResponse.php +++ b/StreamedResponse.php @@ -33,7 +33,7 @@ class StreamedResponse extends Response /** * @param int $status The HTTP status code (200 "OK" by default) */ - public function __construct(callable $callback = null, int $status = 200, array $headers = []) + public function __construct(?callable $callback = null, int $status = 200, array $headers = []) { parent::__construct(null, $status, $headers); @@ -51,25 +51,39 @@ public function __construct(callable $callback = null, int $status = 200, array */ public function setCallback(callable $callback): static { - $this->callback = $callback; + $this->callback = $callback(...); return $this; } + public function getCallback(): ?\Closure + { + if (!isset($this->callback)) { + return null; + } + + return ($this->callback)(...); + } + /** * This method only sends the headers once. * + * @param positive-int|null $statusCode The status code to use, override the statusCode property if set and not null + * * @return $this */ - public function sendHeaders(): static + public function sendHeaders(/* int $statusCode = null */): static { if ($this->headersSent) { return $this; } - $this->headersSent = true; + $statusCode = \func_num_args() > 0 ? func_get_arg(0) : null; + if ($statusCode < 100 || $statusCode >= 200) { + $this->headersSent = true; + } - return parent::sendHeaders(); + return parent::sendHeaders($statusCode); } /** @@ -85,8 +99,8 @@ public function sendContent(): static $this->streamed = true; - if (null === $this->callback) { - throw new \LogicException('The Response callback must not be null.'); + if (!isset($this->callback)) { + throw new \LogicException('The Response callback must be set.'); } ($this->callback)(); diff --git a/Test/Constraint/ResponseCookieValueSame.php b/Test/Constraint/ResponseCookieValueSame.php index b3d375e4c..768007b95 100644 --- a/Test/Constraint/ResponseCookieValueSame.php +++ b/Test/Constraint/ResponseCookieValueSame.php @@ -22,7 +22,7 @@ final class ResponseCookieValueSame extends Constraint private string $path; private ?string $domain; - public function __construct(string $name, string $value, string $path = '/', string $domain = null) + public function __construct(string $name, string $value, string $path = '/', ?string $domain = null) { $this->name = $name; $this->value = $value; @@ -69,9 +69,7 @@ protected function getCookie(Response $response): ?Cookie { $cookies = $response->headers->getCookies(); - $filteredCookies = array_filter($cookies, function (Cookie $cookie) { - return $cookie->getName() === $this->name && $cookie->getPath() === $this->path && $cookie->getDomain() === $this->domain; - }); + $filteredCookies = array_filter($cookies, fn (Cookie $cookie) => $cookie->getName() === $this->name && $cookie->getPath() === $this->path && $cookie->getDomain() === $this->domain); return reset($filteredCookies) ?: null; } diff --git a/Test/Constraint/ResponseHasCookie.php b/Test/Constraint/ResponseHasCookie.php index 9b15aeae8..8eccea9d1 100644 --- a/Test/Constraint/ResponseHasCookie.php +++ b/Test/Constraint/ResponseHasCookie.php @@ -21,7 +21,7 @@ final class ResponseHasCookie extends Constraint private string $path; private ?string $domain; - public function __construct(string $name, string $path = '/', string $domain = null) + public function __construct(string $name, string $path = '/', ?string $domain = null) { $this->name = $name; $this->path = $path; @@ -61,9 +61,7 @@ private function getCookie(Response $response): ?Cookie { $cookies = $response->headers->getCookies(); - $filteredCookies = array_filter($cookies, function (Cookie $cookie) { - return $cookie->getName() === $this->name && $cookie->getPath() === $this->path && $cookie->getDomain() === $this->domain; - }); + $filteredCookies = array_filter($cookies, fn (Cookie $cookie) => $cookie->getName() === $this->name && $cookie->getPath() === $this->path && $cookie->getDomain() === $this->domain); return reset($filteredCookies) ?: null; } diff --git a/Test/Constraint/ResponseHeaderLocationSame.php b/Test/Constraint/ResponseHeaderLocationSame.php new file mode 100644 index 000000000..9286ec715 --- /dev/null +++ b/Test/Constraint/ResponseHeaderLocationSame.php @@ -0,0 +1,65 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Test\Constraint; + +use PHPUnit\Framework\Constraint\Constraint; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; + +final class ResponseHeaderLocationSame extends Constraint +{ + public function __construct(private Request $request, private string $expectedValue) + { + } + + public function toString(): string + { + return sprintf('has header "Location" matching "%s"', $this->expectedValue); + } + + protected function matches($other): bool + { + if (!$other instanceof Response) { + return false; + } + + $location = $other->headers->get('Location'); + + if (null === $location) { + return false; + } + + return $this->toFullUrl($this->expectedValue) === $this->toFullUrl($location); + } + + protected function failureDescription($other): string + { + return 'the Response '.$this->toString(); + } + + private function toFullUrl(string $url): string + { + if (null === parse_url($url, \PHP_URL_PATH)) { + $url .= '/'; + } + + if (str_starts_with($url, '//')) { + return sprintf('%s:%s', $this->request->getScheme(), $url); + } + + if (str_starts_with($url, '/')) { + return $this->request->getSchemeAndHttpHost().$url; + } + + return $url; + } +} diff --git a/Tests/AcceptHeaderTest.php b/Tests/AcceptHeaderTest.php index bf4582430..e972d714e 100644 --- a/Tests/AcceptHeaderTest.php +++ b/Tests/AcceptHeaderTest.php @@ -41,6 +41,8 @@ public static function provideFromStringData() { return [ ['', []], + [';;;', []], + ['0', [new AcceptHeaderItem('0')]], ['gzip', [new AcceptHeaderItem('gzip')]], ['gzip,deflate,sdch', [new AcceptHeaderItem('gzip'), new AcceptHeaderItem('deflate'), new AcceptHeaderItem('sdch')]], ["gzip, deflate\t,sdch", [new AcceptHeaderItem('gzip'), new AcceptHeaderItem('deflate'), new AcceptHeaderItem('sdch')]], diff --git a/Tests/BinaryFileResponseTest.php b/Tests/BinaryFileResponseTest.php index e8a194959..c7d47a4d7 100644 --- a/Tests/BinaryFileResponseTest.php +++ b/Tests/BinaryFileResponseTest.php @@ -344,7 +344,7 @@ public function testAcceptRangeOnUnsafeMethods() $this->assertEquals('none', $response->headers->get('Accept-Ranges')); } - public function testAcceptRangeNotOverriden() + public function testAcceptRangeNotOverridden() { $request = Request::create('/', 'POST'); $response = new BinaryFileResponse(__DIR__.'/File/Fixtures/test.gif', 200, ['Content-Type' => 'application/octet-stream']); diff --git a/Tests/CookieTest.php b/Tests/CookieTest.php index 874758e9d..b55d29cc7 100644 --- a/Tests/CookieTest.php +++ b/Tests/CookieTest.php @@ -87,6 +87,19 @@ public function testNegativeExpirationIsNotPossible() $this->assertSame(0, $cookie->getExpiresTime()); } + public function testMinimalParameters() + { + $constructedCookie = new Cookie('foo'); + + $createdCookie = Cookie::create('foo'); + + $cookie = new Cookie('foo', null, 0, '/', null, null, true, false, 'lax'); + + $this->assertEquals($constructedCookie, $cookie); + + $this->assertEquals($createdCookie, $cookie); + } + public function testGetValue() { $value = 'MyValue'; @@ -187,6 +200,17 @@ public function testIsHttpOnly() $this->assertTrue($cookie->isHttpOnly(), '->isHttpOnly() returns whether the cookie is only transmitted over HTTP'); } + public function testIsPartitioned() + { + $cookie = new Cookie('foo', 'bar', 0, '/', '.myfoodomain.com', true, true, false, 'Lax', true); + + $this->assertTrue($cookie->isPartitioned()); + + $cookie = Cookie::create('foo')->withPartitioned(true); + + $this->assertTrue($cookie->isPartitioned()); + } + public function testCookieIsNotCleared() { $cookie = Cookie::create('foo', 'bar', time() + 3600 * 24); @@ -262,6 +286,20 @@ public function testToString() ->withSameSite(null); $this->assertEquals($expected, (string) $cookie, '->__toString() returns string representation of a cleared cookie if value is NULL'); + $expected = 'foo=deleted; expires='.gmdate('D, d M Y H:i:s T', $expire = time() - 31536001).'; Max-Age=0; path=/admin/; domain=.myfoodomain.com; secure; httponly; samesite=none; partitioned'; + $cookie = new Cookie('foo', null, 1, '/admin/', '.myfoodomain.com', true, true, false, 'none', true); + $this->assertEquals($expected, (string) $cookie, '->__toString() returns string representation of a cleared cookie if value is NULL'); + + $cookie = Cookie::create('foo') + ->withExpires(1) + ->withPath('/admin/') + ->withDomain('.myfoodomain.com') + ->withSecure(true) + ->withHttpOnly(true) + ->withSameSite('none') + ->withPartitioned(true); + $this->assertEquals($expected, (string) $cookie, '->__toString() returns string representation of a cleared cookie if value is NULL'); + $expected = 'foo=bar; path=/; httponly; samesite=lax'; $cookie = Cookie::create('foo', 'bar'); $this->assertEquals($expected, (string) $cookie); @@ -313,6 +351,9 @@ public function testFromString() $cookie = Cookie::fromString('foo=bar', true); $this->assertEquals(Cookie::create('foo', 'bar', 0, '/', null, false, false, false, null), $cookie); + $cookie = Cookie::fromString('foo=bar=', true); + $this->assertEquals(Cookie::create('foo', 'bar=', 0, '/', null, false, false, false, null), $cookie); + $cookie = Cookie::fromString('foo', true); $this->assertEquals(Cookie::create('foo', null, 0, '/', null, false, false, false, null), $cookie); @@ -321,6 +362,9 @@ public function testFromString() $cookie = Cookie::fromString('foo_cookie=foo==; expires=Tue, 22 Sep 2020 06:27:09 GMT; path=/'); $this->assertEquals(Cookie::create('foo_cookie', 'foo==', strtotime('Tue, 22 Sep 2020 06:27:09 GMT'), '/', null, false, false, true, null), $cookie); + + $cookie = Cookie::fromString('foo_cookie=foo==; expires=Tue, 22 Sep 2020 06:27:09 GMT; path=/; secure; httponly; samesite=none; partitioned'); + $this->assertEquals(new Cookie('foo_cookie', 'foo==', strtotime('Tue, 22 Sep 2020 06:27:09 GMT'), '/', null, true, true, true, 'none', true), $cookie); } public function testFromStringWithHttpOnly() diff --git a/Tests/File/FakeFile.php b/Tests/File/FakeFile.php index 8b2f12f41..9bac076ca 100644 --- a/Tests/File/FakeFile.php +++ b/Tests/File/FakeFile.php @@ -15,7 +15,7 @@ class FakeFile extends OrigFile { - private $realpath; + private string $realpath; public function __construct(string $realpath, string $path) { diff --git a/Tests/File/FileTest.php b/Tests/File/FileTest.php index fc806e951..65ce2308f 100644 --- a/Tests/File/FileTest.php +++ b/Tests/File/FileTest.php @@ -20,8 +20,6 @@ */ class FileTest extends TestCase { - protected $file; - public function testGetMimeTypeUsesMimeTypeGuessers() { $file = new File(__DIR__.'/Fixtures/test.gif'); diff --git a/Tests/Fixtures/FooEnum.php b/Tests/Fixtures/FooEnum.php new file mode 100644 index 000000000..a6f56fba1 --- /dev/null +++ b/Tests/Fixtures/FooEnum.php @@ -0,0 +1,17 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests\Fixtures; + +enum FooEnum: int +{ + case Bar = 1; +} diff --git a/Tests/Fixtures/response-functional/deleted_cookie.php b/Tests/Fixtures/response-functional/deleted_cookie.php index 003b0c121..6b54f6614 100644 --- a/Tests/Fixtures/response-functional/deleted_cookie.php +++ b/Tests/Fixtures/response-functional/deleted_cookie.php @@ -34,10 +34,7 @@ $listener = new SessionListener($container); $kernel = new class($r) implements HttpKernelInterface { - /** - * @var Response - */ - private $response; + private Response $response; public function __construct(Response $response) { diff --git a/Tests/Fixtures/response-functional/early_hints.php b/Tests/Fixtures/response-functional/early_hints.php new file mode 100644 index 000000000..90294d9ae --- /dev/null +++ b/Tests/Fixtures/response-functional/early_hints.php @@ -0,0 +1,31 @@ +headers->set('Link', '; rel="preload"; as="style"'); +$r->sendHeaders(103); + +$r->headers->set('Link', '; rel="preload"; as="script"', false); +$r->sendHeaders(103); + +$r->setContent('Hello, Early Hints'); +$r->send(); diff --git a/Tests/HeaderBagTest.php b/Tests/HeaderBagTest.php index 50546311a..d7507fc03 100644 --- a/Tests/HeaderBagTest.php +++ b/Tests/HeaderBagTest.php @@ -45,7 +45,7 @@ public function testGetDate() { $bag = new HeaderBag(['foo' => 'Tue, 4 Sep 2012 20:00:00 +0200']); $headerDate = $bag->getDate('foo'); - $this->assertInstanceOf(\DateTime::class, $headerDate); + $this->assertInstanceOf(\DateTimeImmutable::class, $headerDate); } public function testGetDateNull() diff --git a/Tests/HeaderUtilsTest.php b/Tests/HeaderUtilsTest.php index 73d3f150c..3279b9a53 100644 --- a/Tests/HeaderUtilsTest.php +++ b/Tests/HeaderUtilsTest.php @@ -34,7 +34,7 @@ public static function provideHeaderToSplit(): array [['foo', '123, bar'], 'foo=123, bar', '='], [['foo', '123, bar'], ' foo = 123, bar ', '='], [[['foo', '123'], ['bar']], 'foo=123, bar', ',='], - [[[['foo', '123']], [['bar'], ['foo', '456']]], 'foo=123, bar; foo=456', ',;='], + [[[['foo', '123']], [['bar'], ['foo', '456']]], 'foo=123, bar;; foo=456', ',;='], [[[['foo', 'a,b;c=d']]], 'foo="a,b;c=d"', ',;='], [['foo', 'bar'], 'foo,,,, bar', ','], @@ -46,13 +46,15 @@ public static function provideHeaderToSplit(): array [[['foo_cookie', 'foo=1&bar=2&baz=3'], ['expires', 'Tue, 22-Sep-2020 06:27:09 GMT'], ['path', '/']], 'foo_cookie=foo=1&bar=2&baz=3; expires=Tue, 22-Sep-2020 06:27:09 GMT; path=/', ';='], [[['foo_cookie', 'foo=='], ['expires', 'Tue, 22-Sep-2020 06:27:09 GMT'], ['path', '/']], 'foo_cookie=foo==; expires=Tue, 22-Sep-2020 06:27:09 GMT; path=/', ';='], + [[['foo_cookie', 'foo='], ['expires', 'Tue, 22-Sep-2020 06:27:09 GMT'], ['path', '/']], 'foo_cookie=foo=; expires=Tue, 22-Sep-2020 06:27:09 GMT; path=/', ';='], [[['foo_cookie', 'foo=a=b'], ['expires', 'Tue, 22-Sep-2020 06:27:09 GMT'], ['path', '/']], 'foo_cookie=foo="a=b"; expires=Tue, 22-Sep-2020 06:27:09 GMT; path=/', ';='], // These are not a valid header values. We test that they parse anyway, // and that both the valid and invalid parts are returned. [[], '', ','], [[], ',,,', ','], - [['foo', 'bar', 'baz'], 'foo, "bar", "baz', ','], + [[['', 'foo'], ['bar', '']], '=foo,bar=', ',='], + [['foo', 'foobar', 'baz'], 'foo, foo"bar", "baz', ','], [['foo', 'bar, baz'], 'foo, "bar, baz', ','], [['foo', 'bar, baz\\'], 'foo, "bar, baz\\', ','], [['foo', 'bar, baz\\'], 'foo, "bar, baz\\\\', ','], @@ -147,7 +149,7 @@ public static function provideMakeDispositionFail() /** * @dataProvider provideParseQuery */ - public function testParseQuery(string $query, string $expected = null) + public function testParseQuery(string $query, ?string $expected = null) { $this->assertSame($expected ?? $query, http_build_query(HeaderUtils::parseQuery($query), '', '&')); } diff --git a/Tests/InputBagTest.php b/Tests/InputBagTest.php index 696318e91..e2112726e 100644 --- a/Tests/InputBagTest.php +++ b/Tests/InputBagTest.php @@ -12,11 +12,15 @@ namespace Symfony\Component\HttpFoundation\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\InputBag; +use Symfony\Component\HttpFoundation\Tests\Fixtures\FooEnum; class InputBagTest extends TestCase { + use ExpectDeprecationTrait; + public function testGet() { $bag = new InputBag(['foo' => 'bar', 'null' => null, 'int' => 1, 'float' => 1.0, 'bool' => false, 'stringable' => new class() implements \Stringable { @@ -35,6 +39,58 @@ public function __toString(): string $this->assertFalse($bag->get('bool'), '->get() gets the value of a bool parameter'); } + /** + * @group legacy + */ + public function testGetIntError() + { + $this->expectDeprecation('Since symfony/http-foundation 6.3: Ignoring invalid values when using "Symfony\Component\HttpFoundation\InputBag::getInt(\'foo\')" is deprecated and will throw a "Symfony\Component\HttpFoundation\Exception\BadRequestException" in 7.0; use method "filter()" with flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.'); + + $bag = new InputBag(['foo' => 'bar']); + $result = $bag->getInt('foo'); + $this->assertSame(0, $result); + } + + /** + * @group legacy + */ + public function testGetBooleanError() + { + $this->expectDeprecation('Since symfony/http-foundation 6.3: Ignoring invalid values when using "Symfony\Component\HttpFoundation\InputBag::getBoolean(\'foo\')" is deprecated and will throw a "Symfony\Component\HttpFoundation\Exception\BadRequestException" in 7.0; use method "filter()" with flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.'); + + $bag = new InputBag(['foo' => 'bar']); + $result = $bag->getBoolean('foo'); + $this->assertFalse($result); + } + + public function testGetString() + { + $bag = new InputBag(['integer' => 123, 'bool_true' => true, 'bool_false' => false, 'string' => 'abc', 'stringable' => new class() implements \Stringable { + public function __toString(): string + { + return 'strval'; + } + }]); + + $this->assertSame('123', $bag->getString('integer'), '->getString() gets a value of parameter as string'); + $this->assertSame('abc', $bag->getString('string'), '->getString() gets a value of parameter as string'); + $this->assertSame('', $bag->getString('unknown'), '->getString() returns zero if a parameter is not defined'); + $this->assertSame('foo', $bag->getString('unknown', 'foo'), '->getString() returns the default if a parameter is not defined'); + $this->assertSame('1', $bag->getString('bool_true'), '->getString() returns "1" if a parameter is true'); + $this->assertSame('', $bag->getString('bool_false', 'foo'), '->getString() returns an empty empty string if a parameter is false'); + $this->assertSame('strval', $bag->getString('stringable'), '->getString() gets a value of a stringable paramater as string'); + } + + public function testGetStringExceptionWithArray() + { + $bag = new InputBag(['key' => ['abc']]); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Input value "key" contains a non-scalar value.'); + + $bag->getString('key'); + } + public function testGetDoesNotUseDeepByDefault() { $bag = new InputBag(['foo' => ['bar' => 'moo']]); @@ -64,9 +120,7 @@ public function testFilterCallback() public function testFilterClosure() { $bag = new InputBag(['foo' => 'bar']); - $result = $bag->filter('foo', null, \FILTER_CALLBACK, ['options' => function ($value) { - return strtoupper($value); - }]); + $result = $bag->filter('foo', null, \FILTER_CALLBACK, ['options' => strtoupper(...)]); $this->assertSame('BAR', $result); } @@ -106,4 +160,73 @@ public function testFilterArrayWithoutArrayFlag() $bag = new InputBag(['foo' => ['bar', 'baz']]); $bag->filter('foo', \FILTER_VALIDATE_INT); } + + public function testAdd() + { + $bag = new InputBag(['foo' => 'bar']); + $bag->add(['baz' => 'qux']); + + $this->assertSame('bar', $bag->get('foo'), '->add() does not remove existing parameters'); + $this->assertSame('qux', $bag->get('baz'), '->add() adds new parameters'); + } + + public function testReplace() + { + $bag = new InputBag(['foo' => 'bar']); + $bag->replace(['baz' => 'qux']); + + $this->assertNull($bag->get('foo'), '->replace() removes existing parameters'); + $this->assertSame('qux', $bag->get('baz'), '->replace() adds new parameters'); + } + + public function testGetEnum() + { + $bag = new InputBag(['valid-value' => 1]); + + $this->assertSame(FooEnum::Bar, $bag->getEnum('valid-value', FooEnum::class)); + } + + public function testGetEnumThrowsExceptionWithInvalidValue() + { + $bag = new InputBag(['invalid-value' => 2]); + + $this->expectException(BadRequestException::class); + if (\PHP_VERSION_ID >= 80200) { + $this->expectExceptionMessage('Parameter "invalid-value" cannot be converted to enum: 2 is not a valid backing value for enum Symfony\Component\HttpFoundation\Tests\Fixtures\FooEnum.'); + } else { + $this->expectExceptionMessage('Parameter "invalid-value" cannot be converted to enum: 2 is not a valid backing value for enum "Symfony\Component\HttpFoundation\Tests\Fixtures\FooEnum".'); + } + + $this->assertNull($bag->getEnum('invalid-value', FooEnum::class)); + } + + public function testGetAlnumExceptionWithArray() + { + $bag = new InputBag(['word' => ['foo_BAR_012']]); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Input value "word" contains a non-scalar value.'); + + $bag->getAlnum('word'); + } + + public function testGetAlphaExceptionWithArray() + { + $bag = new InputBag(['word' => ['foo_BAR_012']]); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Input value "word" contains a non-scalar value.'); + + $bag->getAlpha('word'); + } + + public function testGetDigitsExceptionWithArray() + { + $bag = new InputBag(['word' => ['foo_BAR_012']]); + + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Input value "word" contains a non-scalar value.'); + + $bag->getDigits('word'); + } } diff --git a/Tests/IpUtilsTest.php b/Tests/IpUtilsTest.php index bde49e7a9..ce93c69e9 100644 --- a/Tests/IpUtilsTest.php +++ b/Tests/IpUtilsTest.php @@ -12,13 +12,10 @@ namespace Symfony\Component\HttpFoundation\Tests; use PHPUnit\Framework\TestCase; -use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\HttpFoundation\IpUtils; class IpUtilsTest extends TestCase { - use ExpectDeprecationTrait; - public function testSeparateCachesPerProtocol() { $ip = '192.168.52.1'; @@ -169,4 +166,54 @@ public static function getIp4SubnetMaskZeroData() [false, '1.2.3.4', '256.256.256/0'], // invalid CIDR notation ]; } + + /** + * @dataProvider getIsPrivateIpData + */ + public function testIsPrivateIp(string $ip, bool $matches) + { + $this->assertSame($matches, IpUtils::isPrivateIp($ip)); + } + + public static function getIsPrivateIpData(): array + { + return [ + // private + ['127.0.0.1', true], + ['10.0.0.1', true], + ['192.168.0.1', true], + ['172.16.0.1', true], + ['169.254.0.1', true], + ['0.0.0.1', true], + ['240.0.0.1', true], + ['::1', true], + ['fc00::1', true], + ['fe80::1', true], + ['::ffff:0:1', true], + ['fd00::1', true], + + // public + ['104.26.14.6', false], + ['2606:4700:20::681a:e06', false], + ]; + } + + public function testCacheSizeLimit() + { + $ref = new \ReflectionClass(IpUtils::class); + + /** @var array */ + $checkedIps = $ref->getStaticPropertyValue('checkedIps'); + $this->assertIsArray($checkedIps); + + $maxCheckedIps = 1000; + + for ($i = 1; $i < $maxCheckedIps * 1.5; ++$i) { + $ip = '192.168.1.'.str_pad((string) $i, 3, '0'); + + IpUtils::checkIp4($ip, '127.0.0.1'); + } + + $this->assertLessThan($maxCheckedIps, \count($checkedIps)); + } } diff --git a/Tests/JsonResponseTest.php b/Tests/JsonResponseTest.php index e5443c0b8..2058280c5 100644 --- a/Tests/JsonResponseTest.php +++ b/Tests/JsonResponseTest.php @@ -190,6 +190,14 @@ public function testConstructorWithObjectWithoutToStringMethodThrowsAnException( new JsonResponse(new \stdClass(), 200, [], true); } + + public function testSetDataWithNull() + { + $response = new JsonResponse(); + $response->setData(null); + + $this->assertSame('null', $response->getContent()); + } } class JsonSerializableObject implements \JsonSerializable diff --git a/Tests/ParameterBagTest.php b/Tests/ParameterBagTest.php index 1b60fb241..42c1b67da 100644 --- a/Tests/ParameterBagTest.php +++ b/Tests/ParameterBagTest.php @@ -12,11 +12,16 @@ namespace Symfony\Component\HttpFoundation\Tests; use PHPUnit\Framework\TestCase; +use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; use Symfony\Component\HttpFoundation\Exception\BadRequestException; +use Symfony\Component\HttpFoundation\Exception\UnexpectedValueException; use Symfony\Component\HttpFoundation\ParameterBag; +use Symfony\Component\HttpFoundation\Tests\Fixtures\FooEnum; class ParameterBagTest extends TestCase { + use ExpectDeprecationTrait; + public function testConstructor() { $this->testAll(); @@ -111,34 +116,137 @@ public function testHas() public function testGetAlpha() { - $bag = new ParameterBag(['word' => 'foo_BAR_012']); + $bag = new ParameterBag(['word' => 'foo_BAR_012', 'bool' => true, 'integer' => 123]); + + $this->assertSame('fooBAR', $bag->getAlpha('word'), '->getAlpha() gets only alphabetic characters'); + $this->assertSame('', $bag->getAlpha('unknown'), '->getAlpha() returns empty string if a parameter is not defined'); + $this->assertSame('abcDEF', $bag->getAlpha('unknown', 'abc_DEF_012'), '->getAlpha() returns filtered default if a parameter is not defined'); + $this->assertSame('', $bag->getAlpha('integer', 'abc_DEF_012'), '->getAlpha() returns empty string if a parameter is an integer'); + $this->assertSame('', $bag->getAlpha('bool', 'abc_DEF_012'), '->getAlpha() returns empty string if a parameter is a boolean'); + } - $this->assertEquals('fooBAR', $bag->getAlpha('word'), '->getAlpha() gets only alphabetic characters'); - $this->assertEquals('', $bag->getAlpha('unknown'), '->getAlpha() returns empty string if a parameter is not defined'); + public function testGetAlphaExceptionWithArray() + { + $bag = new ParameterBag(['word' => ['foo_BAR_012']]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter value "word" cannot be converted to "string".'); + + $bag->getAlpha('word'); } public function testGetAlnum() { - $bag = new ParameterBag(['word' => 'foo_BAR_012']); + $bag = new ParameterBag(['word' => 'foo_BAR_012', 'bool' => true, 'integer' => 123]); - $this->assertEquals('fooBAR012', $bag->getAlnum('word'), '->getAlnum() gets only alphanumeric characters'); - $this->assertEquals('', $bag->getAlnum('unknown'), '->getAlnum() returns empty string if a parameter is not defined'); + $this->assertSame('fooBAR012', $bag->getAlnum('word'), '->getAlnum() gets only alphanumeric characters'); + $this->assertSame('', $bag->getAlnum('unknown'), '->getAlnum() returns empty string if a parameter is not defined'); + $this->assertSame('abcDEF012', $bag->getAlnum('unknown', 'abc_DEF_012'), '->getAlnum() returns filtered default if a parameter is not defined'); + $this->assertSame('123', $bag->getAlnum('integer', 'abc_DEF_012'), '->getAlnum() returns the number as string if a parameter is an integer'); + $this->assertSame('1', $bag->getAlnum('bool', 'abc_DEF_012'), '->getAlnum() returns 1 if a parameter is true'); + } + + public function testGetAlnumExceptionWithArray() + { + $bag = new ParameterBag(['word' => ['foo_BAR_012']]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter value "word" cannot be converted to "string".'); + + $bag->getAlnum('word'); } public function testGetDigits() { - $bag = new ParameterBag(['word' => 'foo_BAR_012']); + $bag = new ParameterBag(['word' => 'foo_BAR_0+1-2', 'bool' => true, 'integer' => 123]); + + $this->assertSame('012', $bag->getDigits('word'), '->getDigits() gets only digits as string'); + $this->assertSame('', $bag->getDigits('unknown'), '->getDigits() returns empty string if a parameter is not defined'); + $this->assertSame('012', $bag->getDigits('unknown', 'abc_DEF_012'), '->getDigits() returns filtered default if a parameter is not defined'); + $this->assertSame('123', $bag->getDigits('integer', 'abc_DEF_012'), '->getDigits() returns the number as string if a parameter is an integer'); + $this->assertSame('1', $bag->getDigits('bool', 'abc_DEF_012'), '->getDigits() returns 1 if a parameter is true'); + } - $this->assertEquals('012', $bag->getDigits('word'), '->getDigits() gets only digits as string'); - $this->assertEquals('', $bag->getDigits('unknown'), '->getDigits() returns empty string if a parameter is not defined'); + public function testGetDigitsExceptionWithArray() + { + $bag = new ParameterBag(['word' => ['foo_BAR_012']]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter value "word" cannot be converted to "string".'); + + $bag->getDigits('word'); } public function testGetInt() { - $bag = new ParameterBag(['digits' => '0123']); + $bag = new ParameterBag(['digits' => '123', 'bool' => true]); + + $this->assertSame(123, $bag->getInt('digits', 0), '->getInt() gets a value of parameter as integer'); + $this->assertSame(0, $bag->getInt('unknown', 0), '->getInt() returns zero if a parameter is not defined'); + $this->assertSame(10, $bag->getInt('unknown', 10), '->getInt() returns the default if a parameter is not defined'); + $this->assertSame(1, $bag->getInt('bool', 0), '->getInt() returns 1 if a parameter is true'); + } + + /** + * @group legacy + */ + public function testGetIntExceptionWithArray() + { + $this->expectDeprecation(sprintf('Since symfony/http-foundation 6.3: Ignoring invalid values when using "%s::getInt(\'digits\')" is deprecated and will throw an "%s" in 7.0; use method "filter()" with flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.', ParameterBag::class, UnexpectedValueException::class)); + + $bag = new ParameterBag(['digits' => ['123']]); + $result = $bag->getInt('digits', 0); + $this->assertSame(0, $result); + } + + /** + * @group legacy + */ + public function testGetIntExceptionWithInvalid() + { + $this->expectDeprecation(sprintf('Since symfony/http-foundation 6.3: Ignoring invalid values when using "%s::getInt(\'word\')" is deprecated and will throw an "%s" in 7.0; use method "filter()" with flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.', ParameterBag::class, UnexpectedValueException::class)); + + $bag = new ParameterBag(['word' => 'foo_BAR_012']); + $result = $bag->getInt('word', 0); + $this->assertSame(0, $result); + } + + public function testGetString() + { + $bag = new ParameterBag(['integer' => 123, 'bool_true' => true, 'bool_false' => false, 'string' => 'abc', 'stringable' => new class() implements \Stringable { + public function __toString(): string + { + return 'strval'; + } + }]); + + $this->assertSame('123', $bag->getString('integer'), '->getString() gets a value of parameter as string'); + $this->assertSame('abc', $bag->getString('string'), '->getString() gets a value of parameter as string'); + $this->assertSame('', $bag->getString('unknown'), '->getString() returns zero if a parameter is not defined'); + $this->assertSame('foo', $bag->getString('unknown', 'foo'), '->getString() returns the default if a parameter is not defined'); + $this->assertSame('1', $bag->getString('bool_true'), '->getString() returns "1" if a parameter is true'); + $this->assertSame('', $bag->getString('bool_false', 'foo'), '->getString() returns an empty empty string if a parameter is false'); + $this->assertSame('strval', $bag->getString('stringable'), '->getString() gets a value of a stringable paramater as string'); + } + + public function testGetStringExceptionWithArray() + { + $bag = new ParameterBag(['key' => ['abc']]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter value "key" cannot be converted to "string".'); + + $bag->getString('key'); + } - $this->assertEquals(123, $bag->getInt('digits'), '->getInt() gets a value of parameter as integer'); - $this->assertEquals(0, $bag->getInt('unknown'), '->getInt() returns zero if a parameter is not defined'); + public function testGetStringExceptionWithObject() + { + $bag = new ParameterBag(['object' => $this]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter value "object" cannot be converted to "string".'); + + $bag->getString('object'); } public function testFilter() @@ -163,13 +271,13 @@ public function testFilter() // This test is repeated for code-coverage $this->assertEquals('http://example.com/foo', $bag->filter('url', '', \FILTER_VALIDATE_URL, \FILTER_FLAG_PATH_REQUIRED), '->filter() gets a value of parameter as URL with a path'); - $this->assertFalse($bag->filter('dec', '', \FILTER_VALIDATE_INT, [ - 'flags' => \FILTER_FLAG_ALLOW_HEX, + $this->assertNull($bag->filter('dec', '', \FILTER_VALIDATE_INT, [ + 'flags' => \FILTER_FLAG_ALLOW_HEX | \FILTER_NULL_ON_FAILURE, 'options' => ['min_range' => 1, 'max_range' => 0xFF], ]), '->filter() gets a value of parameter as integer between boundaries'); - $this->assertFalse($bag->filter('hex', '', \FILTER_VALIDATE_INT, [ - 'flags' => \FILTER_FLAG_ALLOW_HEX, + $this->assertNull($bag->filter('hex', '', \FILTER_VALIDATE_INT, [ + 'flags' => \FILTER_FLAG_ALLOW_HEX | \FILTER_NULL_ON_FAILURE, 'options' => ['min_range' => 1, 'max_range' => 0xFF], ]), '->filter() gets a value of parameter as integer between boundaries'); @@ -188,9 +296,7 @@ public function testFilterCallback() public function testFilterClosure() { $bag = new ParameterBag(['foo' => 'bar']); - $result = $bag->filter('foo', null, \FILTER_CALLBACK, ['options' => function ($value) { - return strtoupper($value); - }]); + $result = $bag->filter('foo', null, \FILTER_CALLBACK, ['options' => strtoupper(...)]); $this->assertSame('BAR', $result); } @@ -219,11 +325,58 @@ public function testCount() public function testGetBoolean() { - $parameters = ['string_true' => 'true', 'string_false' => 'false']; + $parameters = ['string_true' => 'true', 'string_false' => 'false', 'string' => 'abc']; $bag = new ParameterBag($parameters); $this->assertTrue($bag->getBoolean('string_true'), '->getBoolean() gets the string true as boolean true'); $this->assertFalse($bag->getBoolean('string_false'), '->getBoolean() gets the string false as boolean false'); $this->assertFalse($bag->getBoolean('unknown'), '->getBoolean() returns false if a parameter is not defined'); + $this->assertTrue($bag->getBoolean('unknown', true), '->getBoolean() returns default if a parameter is not defined'); + } + + /** + * @group legacy + */ + public function testGetBooleanExceptionWithInvalid() + { + $this->expectDeprecation(sprintf('Since symfony/http-foundation 6.3: Ignoring invalid values when using "%s::getBoolean(\'invalid\')" is deprecated and will throw an "%s" in 7.0; use method "filter()" with flag "FILTER_NULL_ON_FAILURE" to keep ignoring them.', ParameterBag::class, UnexpectedValueException::class)); + + $bag = new ParameterBag(['invalid' => 'foo']); + $result = $bag->getBoolean('invalid', 0); + $this->assertFalse($result); + } + + public function testGetEnum() + { + $bag = new ParameterBag(['valid-value' => 1]); + + $this->assertSame(FooEnum::Bar, $bag->getEnum('valid-value', FooEnum::class)); + + $this->assertNull($bag->getEnum('invalid-key', FooEnum::class)); + $this->assertSame(FooEnum::Bar, $bag->getEnum('invalid-key', FooEnum::class, FooEnum::Bar)); + } + + public function testGetEnumThrowsExceptionWithNotBackingValue() + { + $bag = new ParameterBag(['invalid-value' => 2]); + + $this->expectException(UnexpectedValueException::class); + if (\PHP_VERSION_ID >= 80200) { + $this->expectExceptionMessage('Parameter "invalid-value" cannot be converted to enum: 2 is not a valid backing value for enum Symfony\Component\HttpFoundation\Tests\Fixtures\FooEnum.'); + } else { + $this->expectExceptionMessage('Parameter "invalid-value" cannot be converted to enum: 2 is not a valid backing value for enum "Symfony\Component\HttpFoundation\Tests\Fixtures\FooEnum".'); + } + + $this->assertNull($bag->getEnum('invalid-value', FooEnum::class)); + } + + public function testGetEnumThrowsExceptionWithInvalidValueType() + { + $bag = new ParameterBag(['invalid-value' => ['foo']]); + + $this->expectException(UnexpectedValueException::class); + $this->expectExceptionMessage('Parameter "invalid-value" cannot be converted to enum: Symfony\Component\HttpFoundation\Tests\Fixtures\FooEnum::from(): Argument #1 ($value) must be of type int, array given.'); + + $this->assertNull($bag->getEnum('invalid-value', FooEnum::class)); } } diff --git a/Tests/RateLimiter/AbstractRequestRateLimiterTest.php b/Tests/RateLimiter/AbstractRequestRateLimiterTest.php index 4e102777a..26f2fac90 100644 --- a/Tests/RateLimiter/AbstractRequestRateLimiterTest.php +++ b/Tests/RateLimiter/AbstractRequestRateLimiterTest.php @@ -14,6 +14,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\RateLimiter\LimiterInterface; +use Symfony\Component\RateLimiter\Policy\NoLimiter; use Symfony\Component\RateLimiter\RateLimit; class AbstractRequestRateLimiterTest extends TestCase @@ -33,6 +34,34 @@ public function testConsume(array $rateLimits, ?RateLimit $expected) $this->assertSame($expected, $rateLimiter->consume(new Request())); } + public function testConsumeWithoutLimiterAddsSpecialNoLimiter() + { + $rateLimiter = new MockAbstractRequestRateLimiter([]); + + try { + $this->assertSame(\PHP_INT_MAX, $rateLimiter->consume(new Request())->getLimit()); + } catch (\TypeError $error) { + if (str_contains($error->getMessage(), 'RateLimit::__construct(): Argument #1 ($availableTokens) must be of type int, float given')) { + $this->markTestSkipped('This test cannot be run on a version of the RateLimiter component that uses \INF instead of \PHP_INT_MAX in NoLimiter.'); + } + + throw $error; + } + } + + public function testResetLimiters() + { + $rateLimiter = new MockAbstractRequestRateLimiter([ + $limiter1 = $this->createMock(LimiterInterface::class), + $limiter2 = $this->createMock(LimiterInterface::class), + ]); + + $limiter1->expects($this->once())->method('reset'); + $limiter2->expects($this->once())->method('reset'); + + $rateLimiter->reset(new Request()); + } + public static function provideRateLimits() { $now = new \DateTimeImmutable(); diff --git a/Tests/RateLimiter/MockAbstractRequestRateLimiter.php b/Tests/RateLimiter/MockAbstractRequestRateLimiter.php index 0acc918bf..31f03c816 100644 --- a/Tests/RateLimiter/MockAbstractRequestRateLimiter.php +++ b/Tests/RateLimiter/MockAbstractRequestRateLimiter.php @@ -20,7 +20,7 @@ class MockAbstractRequestRateLimiter extends AbstractRequestRateLimiter /** * @var LimiterInterface[] */ - private $limiters; + private array $limiters; public function __construct(array $limiters) { diff --git a/Tests/RedirectResponseTest.php b/Tests/RedirectResponseTest.php index 3ed50c3fa..fede448b3 100644 --- a/Tests/RedirectResponseTest.php +++ b/Tests/RedirectResponseTest.php @@ -44,6 +44,13 @@ public function testGenerateLocationHeader() $this->assertEquals('foo.bar', $response->headers->get('Location')); } + public function testGenerateContentTypeHeader() + { + $response = new RedirectResponse('foo.bar'); + + $this->assertSame('text/html; charset=utf-8', $response->headers->get('Content-Type')); + } + public function testGetTargetUrl() { $response = new RedirectResponse('foo.bar'); diff --git a/Tests/RequestMatcher/AttributesRequestMatcherTest.php b/Tests/RequestMatcher/AttributesRequestMatcherTest.php index 3f4fd39c3..dcb2d0b98 100644 --- a/Tests/RequestMatcher/AttributesRequestMatcherTest.php +++ b/Tests/RequestMatcher/AttributesRequestMatcherTest.php @@ -26,9 +26,7 @@ public function test(string $key, string $regexp, bool $expected) $matcher = new AttributesRequestMatcher([$key => $regexp]); $request = Request::create('/admin/foo'); $request->attributes->set('foo', 'foo_bar'); - $request->attributes->set('_controller', function () { - return new Response('foo'); - }); + $request->attributes->set('_controller', fn () => new Response('foo')); $this->assertSame($expected, $matcher->matches($request)); } diff --git a/Tests/RequestMatcher/MethodRequestMatcherTest.php b/Tests/RequestMatcher/MethodRequestMatcherTest.php index d4af82cd9..19db917fe 100644 --- a/Tests/RequestMatcher/MethodRequestMatcherTest.php +++ b/Tests/RequestMatcher/MethodRequestMatcherTest.php @@ -27,6 +27,13 @@ public function test(string $requestMethod, array|string $matcherMethod, bool $i $this->assertSame($isMatch, $matcher->matches($request)); } + public function testAlwaysMatchesOnEmptyMethod() + { + $matcher = new MethodRequestMatcher([]); + $request = Request::create('https://example.com', 'POST'); + $this->assertTrue($matcher->matches($request)); + } + public static function getData() { return [ diff --git a/Tests/RequestMatcher/SchemeRequestMatcherTest.php b/Tests/RequestMatcher/SchemeRequestMatcherTest.php index f8d83645f..6614bfcc2 100644 --- a/Tests/RequestMatcher/SchemeRequestMatcherTest.php +++ b/Tests/RequestMatcher/SchemeRequestMatcherTest.php @@ -42,6 +42,13 @@ public function test(string $requestScheme, array|string $matcherScheme, bool $i } } + public function testAlwaysMatchesOnParamsHeaders() + { + $matcher = new SchemeRequestMatcher([]); + $request = Request::create('sftp://example.com'); + $this->assertTrue($matcher->matches($request)); + } + public static function getData() { return [ diff --git a/Tests/RequestMatcherTest.php b/Tests/RequestMatcherTest.php index 5e028fc4a..cda2b1f23 100644 --- a/Tests/RequestMatcherTest.php +++ b/Tests/RequestMatcherTest.php @@ -173,9 +173,7 @@ public function testAttributesWithClosure() $matcher = new RequestMatcher(); $request = Request::create('/admin/foo'); - $request->attributes->set('_controller', function () { - return new Response('foo'); - }); + $request->attributes->set('_controller', fn () => new Response('foo')); $matcher->matchAttribute('_controller', 'babar'); $this->assertFalse($matcher->matches($request)); diff --git a/Tests/RequestTest.php b/Tests/RequestTest.php index 1ed4e5fcc..00ce7dee1 100644 --- a/Tests/RequestTest.php +++ b/Tests/RequestTest.php @@ -13,6 +13,7 @@ use PHPUnit\Framework\TestCase; use Symfony\Bridge\PhpUnit\ExpectDeprecationTrait; +use Symfony\Component\HttpFoundation\Exception\BadRequestException; use Symfony\Component\HttpFoundation\Exception\ConflictingHeadersException; use Symfony\Component\HttpFoundation\Exception\JsonException; use Symfony\Component\HttpFoundation\Exception\SuspiciousOperationException; @@ -305,9 +306,34 @@ public function testCreateWithRequestUri() $this->assertTrue($request->isSecure()); // Fragment should not be included in the URI - $request = Request::create('http://test.com/foo#bar'); - $request->server->set('REQUEST_URI', 'http://test.com/foo#bar'); + $request = Request::create('http://test.com/foo#bar\\baz'); + $request->server->set('REQUEST_URI', 'http://test.com/foo#bar\\baz'); $this->assertEquals('http://test.com/foo', $request->getUri()); + + $request = Request::create('http://test.com/foo?bar=f\\o'); + $this->assertEquals('http://test.com/foo?bar=f%5Co', $request->getUri()); + $this->assertEquals('/foo', $request->getPathInfo()); + $this->assertEquals('bar=f%5Co', $request->getQueryString()); + } + + /** + * @testWith ["http://foo.com\\bar"] + * ["\\\\foo.com/bar"] + * ["a\rb"] + * ["a\nb"] + * ["a\tb"] + * ["\u0000foo"] + * ["foo\u0000"] + * [" foo"] + * ["foo "] + * [":"] + */ + public function testCreateWithBadRequestUri(string $uri) + { + $this->expectException(BadRequestException::class); + $this->expectExceptionMessage('Invalid URI'); + + Request::create($uri); } /** @@ -1300,6 +1326,20 @@ public function testToArray() $this->assertEquals(['foo' => 'bar'], $req->toArray()); } + public function testGetPayload() + { + $req = new Request([], [], [], [], [], [], json_encode(['foo' => 'bar'])); + $this->assertSame(['foo' => 'bar'], $req->getPayload()->all()); + $req->getPayload()->set('new', 'key'); + $this->assertSame(['foo' => 'bar'], $req->getPayload()->all()); + + $req = new Request([], ['foo' => 'bar'], [], [], [], [], json_encode(['baz' => 'qux'])); + $this->assertSame(['foo' => 'bar'], $req->getPayload()->all()); + + $req = new Request([], [], [], [], [], [], ''); + $this->assertSame([], $req->getPayload()->all()); + } + /** * @dataProvider provideOverloadedMethods */ @@ -1869,6 +1909,62 @@ public static function getBaseUrlData() ]; } + /** + * @dataProvider baseUriDetectionOnIisWithRewriteData + */ + public function testBaseUriDetectionOnIisWithRewrite(array $server, string $expectedBaseUrl, string $expectedPathInfo) + { + $request = new Request([], [], [], [], [], $server); + + self::assertSame($expectedBaseUrl, $request->getBaseUrl()); + self::assertSame($expectedPathInfo, $request->getPathInfo()); + } + + public static function baseUriDetectionOnIisWithRewriteData(): \Generator + { + yield 'No rewrite' => [ + [ + 'PATH_INFO' => '/foo/bar', + 'PHP_SELF' => '/routingtest/index.php/foo/bar', + 'REQUEST_URI' => '/routingtest/index.php/foo/bar', + 'SCRIPT_FILENAME' => 'C:/Users/derrabus/Projects/routing-test/public/index.php', + 'SCRIPT_NAME' => '/routingtest/index.php', + ], + '/routingtest/index.php', + '/foo/bar', + ]; + + yield 'Rewrite with correct case' => [ + [ + 'IIS_WasUrlRewritten' => '1', + 'PATH_INFO' => '/foo/bar', + 'PHP_SELF' => '/routingtest/index.php/foo/bar', + 'REQUEST_URI' => '/routingtest/foo/bar', + 'SCRIPT_FILENAME' => 'C:/Users/derrabus/Projects/routing-test/public/index.php', + 'SCRIPT_NAME' => '/routingtest/index.php', + 'UNENCODED_URL' => '/routingtest/foo/bar', + ], + '/routingtest', + '/foo/bar', + ]; + + // ISS with UrlRewriteModule might report SCRIPT_NAME/PHP_SELF with wrong case + // see https://github.com/php/php-src/issues/11981 + yield 'Rewrite with case mismatch' => [ + [ + 'IIS_WasUrlRewritten' => '1', + 'PATH_INFO' => '/foo/bar', + 'PHP_SELF' => '/routingtest/index.php/foo/bar', + 'REQUEST_URI' => '/RoutingTest/foo/bar', + 'SCRIPT_FILENAME' => 'C:/Users/derrabus/Projects/routing-test/public/index.php', + 'SCRIPT_NAME' => '/routingtest/index.php', + 'UNENCODED_URL' => '/RoutingTest/foo/bar', + ], + '/RoutingTest', + '/foo/bar', + ]; + } + /** * @dataProvider urlencodedStringPrefixData */ @@ -1899,7 +1995,7 @@ private function disableHttpMethodParameterOverride() { $class = new \ReflectionClass(Request::class); $property = $class->getProperty('httpMethodParameterOverride'); - $property->setValue(false); + $property->setValue(null, false); } private function getRequestInstanceForClientIpTests(string $remoteAddr, ?string $httpForwardedFor, ?array $trustedProxies): Request @@ -2136,9 +2232,7 @@ public function testSetTrustedHostsDoesNotBreakOnSpecialCharacters() public function testFactory() { - Request::setFactory(function (array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null) { - return new NewRequest(); - }); + Request::setFactory(fn (array $query = [], array $request = [], array $attributes = [], array $cookies = [], array $files = [], array $server = [], $content = null) => new NewRequest()); $this->assertEquals('foo', Request::create('/')->getFoo()); @@ -2482,6 +2576,23 @@ public function testTrustedProxiesRemoteAddr($serverRemoteAddr, $trustedProxies, $this->assertSame($result, Request::getTrustedProxies()); } + public function testTrustedValuesCache() + { + $request = Request::create('http://example.com/'); + $request->server->set('REMOTE_ADDR', '3.3.3.3'); + $request->headers->set('X_FORWARDED_FOR', '1.1.1.1, 2.2.2.2'); + $request->headers->set('X_FORWARDED_PROTO', 'https'); + + $this->assertFalse($request->isSecure()); + + Request::setTrustedProxies(['3.3.3.3', '2.2.2.2'], Request::HEADER_X_FORWARDED_FOR | Request::HEADER_X_FORWARDED_HOST | Request::HEADER_X_FORWARDED_PORT | Request::HEADER_X_FORWARDED_PROTO); + $this->assertTrue($request->isSecure()); + + // Header is changed, cache must not be hit now + $request->headers->set('X_FORWARDED_PROTO', 'http'); + $this->assertFalse($request->isSecure()); + } + public static function trustedProxiesRemoteAddr() { return [ @@ -2556,6 +2667,16 @@ public function testReservedFlags() $this->assertNotSame(0b10000000, $value, sprintf('The constant "%s" should not use the reserved value "0b10000000".', $constant)); } } + + /** + * @group legacy + */ + public function testInvalidUriCreationDeprecated() + { + $this->expectDeprecation('Since symfony/http-foundation 6.3: Calling "Symfony\Component\HttpFoundation\Request::create()" with an invalid URI is deprecated.'); + $request = Request::create('/invalid-path:123'); + $this->assertEquals('http://localhost/invalid-path:123', $request->getUri()); + } } class RequestContentProxy extends Request diff --git a/Tests/ResponseFunctionalTest.php b/Tests/ResponseFunctionalTest.php index 5cffa87d0..1b3566a2c 100644 --- a/Tests/ResponseFunctionalTest.php +++ b/Tests/ResponseFunctionalTest.php @@ -11,11 +11,13 @@ namespace Symfony\Component\HttpFoundation\Tests; -use PHPUnit\Framework\SkippedTestSuiteError; use PHPUnit\Framework\TestCase; +use Symfony\Component\Process\ExecutableFinder; +use Symfony\Component\Process\Process; class ResponseFunctionalTest extends TestCase { + /** @var resource|false */ private static $server; public static function setUpBeforeClass(): void @@ -25,7 +27,7 @@ public static function setUpBeforeClass(): void 2 => ['file', '/dev/null', 'w'], ]; if (!self::$server = @proc_open('exec '.\PHP_BINARY.' -S localhost:8054', $spec, $pipes, __DIR__.'/Fixtures/response-functional')) { - throw new SkippedTestSuiteError('PHP server unable to start.'); + self::markTestSkipped('PHP server unable to start.'); } sleep(1); } @@ -44,14 +46,38 @@ public static function tearDownAfterClass(): void public function testCookie($fixture) { $result = file_get_contents(sprintf('http://localhost:8054/%s.php', $fixture)); - $result = preg_replace_callback('/expires=[^;]++/', function ($m) { return str_replace('-', ' ', $m[0]); }, $result); + $result = preg_replace_callback('/expires=[^;]++/', fn ($m) => str_replace('-', ' ', $m[0]), $result); $this->assertStringMatchesFormatFile(__DIR__.sprintf('/Fixtures/response-functional/%s.expected', $fixture), $result); } public static function provideCookie() { foreach (glob(__DIR__.'/Fixtures/response-functional/*.php') as $file) { - yield [pathinfo($file, \PATHINFO_FILENAME)]; + if (str_contains($file, 'cookie')) { + yield [pathinfo($file, \PATHINFO_FILENAME)]; + } } } + + /** + * @group integration + */ + public function testInformationalResponse() + { + if (!(new ExecutableFinder())->find('curl')) { + $this->markTestSkipped('curl is not installed'); + } + + if (!($fp = @fsockopen('localhost', 80, $errorCode, $errorMessage, 2))) { + $this->markTestSkipped('FrankenPHP is not running'); + } + fclose($fp); + + $p = new Process(['curl', '-v', 'http://localhost/early_hints.php']); + $p->run(); + $output = $p->getErrorOutput(); + + $this->assertSame(3, preg_match_all('#Link: ; rel="preload"; as="style"#', $output)); + $this->assertSame(2, preg_match_all('#Link: ; rel="preload"; as="script"#', $output)); + } } diff --git a/Tests/ResponseHeaderBagTest.php b/Tests/ResponseHeaderBagTest.php index 8165e4374..9e61dd684 100644 --- a/Tests/ResponseHeaderBagTest.php +++ b/Tests/ResponseHeaderBagTest.php @@ -136,6 +136,14 @@ public function testClearCookieSamesite() $this->assertSetCookieHeader('foo=deleted; expires='.gmdate('D, d M Y H:i:s T', time() - 31536001).'; Max-Age=0; path=/; secure; samesite=none', $bag); } + public function testClearCookiePartitioned() + { + $bag = new ResponseHeaderBag([]); + + $bag->clearCookie('foo', '/', null, true, false, 'none', true); + $this->assertSetCookieHeader('foo=deleted; expires='.gmdate('D, d M Y H:i:s T', time() - 31536001).'; Max-Age=0; path=/; secure; samesite=none; partitioned', $bag); + } + public function testReplace() { $bag = new ResponseHeaderBag([]); diff --git a/Tests/ResponseTest.php b/Tests/ResponseTest.php index 7ab060ec1..007842476 100644 --- a/Tests/ResponseTest.php +++ b/Tests/ResponseTest.php @@ -42,6 +42,17 @@ public function testSendHeaders() $this->assertSame($response, $headers); } + public function testSendInformationalResponse() + { + $response = new Response(); + $response->sendHeaders(103); + + // Informational responses must not override the main status code + $this->assertSame(200, $response->getStatusCode()); + + $response->sendHeaders(); + } + public function testSend() { $response = new Response(); @@ -537,6 +548,16 @@ public function testContentTypeCharset() $this->assertEquals('text/css; charset=UTF-8', $response->headers->get('Content-Type')); } + public function testContentTypeIsNull() + { + $response = new Response('foo'); + $response->headers->set('Content-Type', null); + + $response->prepare(new Request()); + + $this->expectNotToPerformAssertions(); + } + public function testPrepareDoesNothingIfContentTypeIsSet() { $response = new Response('foo'); diff --git a/Tests/ServerBagTest.php b/Tests/ServerBagTest.php index e26714bc4..3d675c512 100644 --- a/Tests/ServerBagTest.php +++ b/Tests/ServerBagTest.php @@ -177,4 +177,20 @@ public function testItDoesNotOverwriteTheAuthorizationHeaderIfItIsAlreadySet() 'PHP_AUTH_PW' => '', ], $bag->getHeaders()); } + + /** + * An HTTP request without content-type and content-length will result in + * the variables $_SERVER['CONTENT_TYPE'] and $_SERVER['CONTENT_LENGTH'] + * containing an empty string in PHP. + */ + public function testRequestWithoutContentTypeAndContentLength() + { + $bag = new ServerBag([ + 'CONTENT_TYPE' => '', + 'CONTENT_LENGTH' => '', + 'HTTP_USER_AGENT' => 'foo', + ]); + + $this->assertSame(['USER_AGENT' => 'foo'], $bag->getHeaders()); + } } diff --git a/Tests/Session/Attribute/AttributeBagTest.php b/Tests/Session/Attribute/AttributeBagTest.php index 273efddf1..afd281f0a 100644 --- a/Tests/Session/Attribute/AttributeBagTest.php +++ b/Tests/Session/Attribute/AttributeBagTest.php @@ -21,12 +21,9 @@ */ class AttributeBagTest extends TestCase { - private $array = []; + private array $array = []; - /** - * @var AttributeBag - */ - private $bag; + private ?AttributeBag $bag = null; protected function setUp(): void { diff --git a/Tests/Session/Flash/AutoExpireFlashBagTest.php b/Tests/Session/Flash/AutoExpireFlashBagTest.php index ba2687199..6a6510a57 100644 --- a/Tests/Session/Flash/AutoExpireFlashBagTest.php +++ b/Tests/Session/Flash/AutoExpireFlashBagTest.php @@ -21,12 +21,9 @@ */ class AutoExpireFlashBagTest extends TestCase { - /** - * @var \Symfony\Component\HttpFoundation\Session\Flash\AutoExpireFlashBag - */ - private $bag; + protected array $array = []; - protected $array = []; + private FlashBag $bag; protected function setUp(): void { @@ -38,7 +35,7 @@ protected function setUp(): void protected function tearDown(): void { - $this->bag = null; + unset($this->bag); parent::tearDown(); } diff --git a/Tests/Session/Flash/FlashBagTest.php b/Tests/Session/Flash/FlashBagTest.php index 24dbbfe98..59e3f1f0e 100644 --- a/Tests/Session/Flash/FlashBagTest.php +++ b/Tests/Session/Flash/FlashBagTest.php @@ -21,12 +21,9 @@ */ class FlashBagTest extends TestCase { - /** - * @var \Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface - */ - private $bag; + protected array $array = []; - protected $array = []; + private FlashBag $bag; protected function setUp(): void { @@ -38,7 +35,7 @@ protected function setUp(): void protected function tearDown(): void { - $this->bag = null; + unset($this->bag); parent::tearDown(); } diff --git a/Tests/Session/SessionTest.php b/Tests/Session/SessionTest.php index 56011ddb5..56ef60806 100644 --- a/Tests/Session/SessionTest.php +++ b/Tests/Session/SessionTest.php @@ -17,8 +17,10 @@ use Symfony\Component\HttpFoundation\Session\Flash\FlashBagInterface; use Symfony\Component\HttpFoundation\Session\Session; use Symfony\Component\HttpFoundation\Session\SessionBagProxy; +use Symfony\Component\HttpFoundation\Session\SessionInterface; use Symfony\Component\HttpFoundation\Session\Storage\MetadataBag; use Symfony\Component\HttpFoundation\Session\Storage\MockArraySessionStorage; +use Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface; /** * SessionTest. @@ -29,15 +31,8 @@ */ class SessionTest extends TestCase { - /** - * @var \Symfony\Component\HttpFoundation\Session\Storage\SessionStorageInterface - */ - protected $storage; - - /** - * @var \Symfony\Component\HttpFoundation\Session\SessionInterface - */ - protected $session; + protected SessionStorageInterface $storage; + protected SessionInterface $session; protected function setUp(): void { @@ -45,12 +40,6 @@ protected function setUp(): void $this->session = new Session($this->storage, new AttributeBag(), new FlashBag()); } - protected function tearDown(): void - { - $this->storage = null; - $this->session = null; - } - public function testStart() { $this->assertEquals('', $this->session->getId()); diff --git a/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php b/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php index 0e13f13fb..a582909a0 100644 --- a/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php +++ b/Tests/Session/Storage/Handler/AbstractRedisSessionHandlerTestCase.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler; use PHPUnit\Framework\TestCase; +use Relay\Relay; use Symfony\Component\HttpFoundation\Session\Storage\Handler\RedisSessionHandler; /** @@ -23,17 +24,10 @@ abstract class AbstractRedisSessionHandlerTestCase extends TestCase { protected const PREFIX = 'prefix_'; - /** - * @var RedisSessionHandler - */ - protected $storage; - - /** - * @var \Redis|\RedisArray|\RedisCluster|\Predis\Client - */ - protected $redisClient; + protected RedisSessionHandler $storage; + protected \Redis|Relay|\RedisArray|\RedisCluster|\Predis\Client $redisClient; - abstract protected function createRedisClient(string $host): \Redis|\RedisArray|\RedisCluster|\Predis\Client; + abstract protected function createRedisClient(string $host): \Redis|Relay|\RedisArray|\RedisCluster|\Predis\Client; protected function setUp(): void { @@ -57,14 +51,6 @@ protected function setUp(): void ); } - protected function tearDown(): void - { - $this->redisClient = null; - $this->storage = null; - - parent::tearDown(); - } - public function testOpenSession() { $this->assertTrue($this->storage->open('', '')); @@ -103,6 +89,7 @@ public function testUseSessionGcMaxLifetimeAsTimeToLive() public function testDestroySession() { + $this->storage->open('', 'test'); $this->redisClient->set(self::PREFIX.'id', 'foo'); $this->assertTrue((bool) $this->redisClient->exists(self::PREFIX.'id')); diff --git a/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php b/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php index 8e42f8427..27fb57da4 100644 --- a/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/AbstractSessionHandlerTest.php @@ -11,11 +11,11 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler; -use PHPUnit\Framework\SkippedTestSuiteError; use PHPUnit\Framework\TestCase; class AbstractSessionHandlerTest extends TestCase { + /** @var resource|false */ private static $server; public static function setUpBeforeClass(): void @@ -25,7 +25,7 @@ public static function setUpBeforeClass(): void 2 => ['file', '/dev/null', 'w'], ]; if (!self::$server = @proc_open('exec '.\PHP_BINARY.' -S localhost:8053', $spec, $pipes, __DIR__.'/Fixtures')) { - throw new SkippedTestSuiteError('PHP server unable to start.'); + self::markTestSkipped('PHP server unable to start.'); } sleep(1); } @@ -46,7 +46,7 @@ public function testSession($fixture) $context = ['http' => ['header' => "Cookie: sid=123abc\r\n"]]; $context = stream_context_create($context); $result = file_get_contents(sprintf('http://localhost:8053/%s.php', $fixture), false, $context); - $result = preg_replace_callback('/expires=[^;]++/', function ($m) { return str_replace('-', ' ', $m[0]); }, $result); + $result = preg_replace_callback('/expires=[^;]++/', fn ($m) => str_replace('-', ' ', $m[0]), $result); $this->assertStringEqualsFile(__DIR__.sprintf('/Fixtures/%s.expected', $fixture), $result); } diff --git a/Tests/Session/Storage/Handler/Fixtures/invalid_regenerate.php b/Tests/Session/Storage/Handler/Fixtures/invalid_regenerate.php index 2798442a9..d7ec890d9 100644 --- a/Tests/Session/Storage/Handler/Fixtures/invalid_regenerate.php +++ b/Tests/Session/Storage/Handler/Fixtures/invalid_regenerate.php @@ -17,4 +17,4 @@ echo empty($_SESSION) ? '$_SESSION is empty' : '$_SESSION is not empty'; echo "\n"; -ob_start(function ($buffer) { return preg_replace('~_sf2_meta.*$~m', '', str_replace(session_id(), 'random_session_id', $buffer)); }); +ob_start(fn ($buffer) => preg_replace('~_sf2_meta.*$~m', '', str_replace(session_id(), 'random_session_id', $buffer))); diff --git a/Tests/Session/Storage/Handler/Fixtures/regenerate.php b/Tests/Session/Storage/Handler/Fixtures/regenerate.php index a0f635c87..b85849595 100644 --- a/Tests/Session/Storage/Handler/Fixtures/regenerate.php +++ b/Tests/Session/Storage/Handler/Fixtures/regenerate.php @@ -7,4 +7,4 @@ session_regenerate_id(true); -ob_start(function ($buffer) { return str_replace(session_id(), 'random_session_id', $buffer); }); +ob_start(fn ($buffer) => str_replace(session_id(), 'random_session_id', $buffer)); diff --git a/Tests/Session/Storage/Handler/Fixtures/storage.php b/Tests/Session/Storage/Handler/Fixtures/storage.php index 96dca3c2c..a86c82056 100644 --- a/Tests/Session/Storage/Handler/Fixtures/storage.php +++ b/Tests/Session/Storage/Handler/Fixtures/storage.php @@ -21,4 +21,4 @@ echo empty($_SESSION) ? '$_SESSION is empty' : '$_SESSION is not empty'; -ob_start(function ($buffer) { return str_replace(session_id(), 'random_session_id', $buffer); }); +ob_start(fn ($buffer) => str_replace(session_id(), 'random_session_id', $buffer)); diff --git a/Tests/Session/Storage/Handler/Fixtures/with_samesite.php b/Tests/Session/Storage/Handler/Fixtures/with_samesite.php index fc2c41828..a005362ce 100644 --- a/Tests/Session/Storage/Handler/Fixtures/with_samesite.php +++ b/Tests/Session/Storage/Handler/Fixtures/with_samesite.php @@ -10,4 +10,4 @@ $_SESSION = ['foo' => 'bar']; -ob_start(function ($buffer) { return str_replace(session_id(), 'random_session_id', $buffer); }); +ob_start(fn ($buffer) => str_replace(session_id(), 'random_session_id', $buffer)); diff --git a/Tests/Session/Storage/Handler/Fixtures/with_samesite_and_migration.php b/Tests/Session/Storage/Handler/Fixtures/with_samesite_and_migration.php index a28b6fedf..13c951ee3 100644 --- a/Tests/Session/Storage/Handler/Fixtures/with_samesite_and_migration.php +++ b/Tests/Session/Storage/Handler/Fixtures/with_samesite_and_migration.php @@ -12,4 +12,4 @@ $storage->regenerate(true); -ob_start(function ($buffer) { return preg_replace('~_sf2_meta.*$~m', '', str_replace(session_id(), 'random_session_id', $buffer)); }); +ob_start(fn ($buffer) => preg_replace('~_sf2_meta.*$~m', '', str_replace(session_id(), 'random_session_id', $buffer))); diff --git a/Tests/Session/Storage/Handler/MarshallingSessionHandlerTest.php b/Tests/Session/Storage/Handler/MarshallingSessionHandlerTest.php index 7216cdd1e..894a71589 100644 --- a/Tests/Session/Storage/Handler/MarshallingSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/MarshallingSessionHandlerTest.php @@ -22,15 +22,8 @@ */ class MarshallingSessionHandlerTest extends TestCase { - /** - * @var MockObject|\SessionHandlerInterface - */ - protected $handler; - - /** - * @var MockObject|MarshallerInterface - */ - protected $marshaller; + protected MockObject&\SessionHandlerInterface $handler; + protected MockObject&MarshallerInterface $marshaller; protected function setUp(): void { diff --git a/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php b/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php index d5e84d181..60bae22c3 100644 --- a/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/MemcachedSessionHandlerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Session\Storage\Handler\MemcachedSessionHandler; @@ -24,12 +25,8 @@ class MemcachedSessionHandlerTest extends TestCase private const PREFIX = 'prefix_'; private const TTL = 1000; - /** - * @var MemcachedSessionHandler - */ - protected $storage; - - protected $memcached; + protected MemcachedSessionHandler $storage; + protected MockObject&\Memcached $memcached; protected function setUp(): void { @@ -40,7 +37,7 @@ protected function setUp(): void } $r = new \ReflectionClass(\Memcached::class); - $methodsToMock = array_map(function ($m) { return $m->name; }, $r->getMethods(\ReflectionMethod::IS_PUBLIC)); + $methodsToMock = array_map(fn ($m) => $m->name, $r->getMethods(\ReflectionMethod::IS_PUBLIC)); $methodsToMock = array_diff($methodsToMock, ['getDelayed', 'getDelayedByKey']); $this->memcached = $this->getMockBuilder(\Memcached::class) @@ -54,13 +51,6 @@ protected function setUp(): void ); } - protected function tearDown(): void - { - $this->memcached = null; - $this->storage = null; - parent::tearDown(); - } - public function testOpenSession() { $this->assertTrue($this->storage->open('', '')); @@ -119,6 +109,7 @@ public function testWriteSessionWithLargeTTL() public function testDestroySession() { + $this->storage->open('', 'sid'); $this->memcached ->expects($this->once()) ->method('delete') diff --git a/Tests/Session/Storage/Handler/MigratingSessionHandlerTest.php b/Tests/Session/Storage/Handler/MigratingSessionHandlerTest.php index f56f753af..959ffab97 100644 --- a/Tests/Session/Storage/Handler/MigratingSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/MigratingSessionHandlerTest.php @@ -11,14 +11,15 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Session\Storage\Handler\MigratingSessionHandler; class MigratingSessionHandlerTest extends TestCase { - private $dualHandler; - private $currentHandler; - private $writeOnlyHandler; + private MigratingSessionHandler $dualHandler; + private MockObject&\SessionHandlerInterface $currentHandler; + private MockObject&\SessionHandlerInterface $writeOnlyHandler; protected function setUp(): void { @@ -51,6 +52,8 @@ public function testClose() public function testDestroy() { + $this->dualHandler->open('/path/to/save/location', 'xyz'); + $sessionId = 'xyz'; $this->currentHandler->expects($this->once()) diff --git a/Tests/Session/Storage/Handler/MongoDbSessionHandlerTest.php b/Tests/Session/Storage/Handler/MongoDbSessionHandlerTest.php index 30e8cc0f9..04dfdbef8 100644 --- a/Tests/Session/Storage/Handler/MongoDbSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/MongoDbSessionHandlerTest.php @@ -11,55 +11,98 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler; +use MongoDB\BSON\Binary; +use MongoDB\BSON\UTCDateTime; use MongoDB\Client; -use PHPUnit\Framework\MockObject\MockObject; +use MongoDB\Driver\BulkWrite; +use MongoDB\Driver\Command; +use MongoDB\Driver\Exception\CommandException; +use MongoDB\Driver\Exception\ConnectionException; +use MongoDB\Driver\Manager; +use MongoDB\Driver\Query; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Session\Storage\Handler\MongoDbSessionHandler; +require_once __DIR__.'/stubs/mongodb.php'; + /** * @author Markus Bachmann * + * @group integration * @group time-sensitive * * @requires extension mongodb */ class MongoDbSessionHandlerTest extends TestCase { - /** - * @var MockObject&Client - */ - private $mongo; - private $storage; - public $options; + private const DABASE_NAME = 'sf-test'; + private const COLLECTION_NAME = 'session-test'; + + public array $options; + private Manager $manager; + private MongoDbSessionHandler $storage; protected function setUp(): void { parent::setUp(); - if (!class_exists(Client::class)) { - $this->markTestSkipped('The mongodb/mongodb package is required.'); - } + $this->manager = new Manager('mongodb://'.getenv('MONGODB_HOST')); - $this->mongo = $this->getMockBuilder(Client::class) - ->disableOriginalConstructor() - ->getMock(); + try { + $this->manager->executeCommand(self::DABASE_NAME, new Command(['ping' => 1])); + } catch (ConnectionException $e) { + $this->markTestSkipped(sprintf('MongoDB Server "%s" not running: %s', getenv('MONGODB_HOST'), $e->getMessage())); + } $this->options = [ 'id_field' => '_id', 'data_field' => 'data', 'time_field' => 'time', 'expiry_field' => 'expires_at', - 'database' => 'sf-test', - 'collection' => 'session-test', + 'database' => self::DABASE_NAME, + 'collection' => self::COLLECTION_NAME, ]; - $this->storage = new MongoDbSessionHandler($this->mongo, $this->options); + $this->storage = new MongoDbSessionHandler($this->manager, $this->options); } - public function testConstructorShouldThrowExceptionForMissingOptions() + public function testCreateFromClient() + { + $client = $this->createMock(Client::class); + $client->expects($this->once()) + ->method('getManager') + ->willReturn($this->manager); + + $this->storage = new MongoDbSessionHandler($client, $this->options); + $this->storage->write('foo', 'bar'); + + $this->assertCount(1, $this->getSessions()); + } + + protected function tearDown(): void + { + try { + $this->manager->executeCommand(self::DABASE_NAME, new Command(['drop' => self::COLLECTION_NAME])); + } catch (CommandException $e) { + // The server may return a NamespaceNotFound error if the collection does not exist + if (26 !== $e->getCode()) { + throw $e; + } + } + } + + /** @dataProvider provideInvalidOptions */ + public function testConstructorShouldThrowExceptionForMissingOptions(array $options) { $this->expectException(\InvalidArgumentException::class); - new MongoDbSessionHandler($this->mongo, []); + new MongoDbSessionHandler($this->manager, $options); + } + + public static function provideInvalidOptions(): iterable + { + yield 'empty' => [[]]; + yield 'collection missing' => [['database' => 'foo']]; + yield 'database missing' => [['collection' => 'foo']]; } public function testOpenMethodAlwaysReturnTrue() @@ -74,142 +117,95 @@ public function testCloseMethodAlwaysReturnTrue() public function testRead() { - $collection = $this->createMongoCollectionMock(); - - $this->mongo->expects($this->once()) - ->method('selectCollection') - ->with($this->options['database'], $this->options['collection']) - ->willReturn($collection); - - // defining the timeout before the actual method call - // allows to test for "greater than" values in the $criteria - $testTimeout = time() + 1; - - $collection->expects($this->once()) - ->method('findOne') - ->willReturnCallback(function ($criteria) use ($testTimeout) { - $this->assertArrayHasKey($this->options['id_field'], $criteria); - $this->assertEquals('foo', $criteria[$this->options['id_field']]); - - $this->assertArrayHasKey($this->options['expiry_field'], $criteria); - $this->assertArrayHasKey('$gte', $criteria[$this->options['expiry_field']]); - - $this->assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $criteria[$this->options['expiry_field']]['$gte']); - $this->assertGreaterThanOrEqual(round((string) $criteria[$this->options['expiry_field']]['$gte'] / 1000), $testTimeout); - - return [ - $this->options['id_field'] => 'foo', - $this->options['expiry_field'] => new \MongoDB\BSON\UTCDateTime(), - $this->options['data_field'] => new \MongoDB\BSON\Binary('bar', \MongoDB\BSON\Binary::TYPE_OLD_BINARY), - ]; - }); - + $this->insertSession('foo', 'bar', 0); $this->assertEquals('bar', $this->storage->read('foo')); } - public function testWrite() + public function testReadNotFound() { - $collection = $this->createMongoCollectionMock(); - - $this->mongo->expects($this->once()) - ->method('selectCollection') - ->with($this->options['database'], $this->options['collection']) - ->willReturn($collection); - - $collection->expects($this->once()) - ->method('updateOne') - ->willReturnCallback(function ($criteria, $updateData, $options) { - $this->assertEquals([$this->options['id_field'] => 'foo'], $criteria); - $this->assertEquals(['upsert' => true], $options); + $this->insertSession('foo', 'bar', 0); + $this->assertEquals('', $this->storage->read('foobar')); + } - $data = $updateData['$set']; - $expectedExpiry = time() + (int) \ini_get('session.gc_maxlifetime'); - $this->assertInstanceOf(\MongoDB\BSON\Binary::class, $data[$this->options['data_field']]); - $this->assertEquals('bar', $data[$this->options['data_field']]->getData()); - $this->assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $data[$this->options['time_field']]); - $this->assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $data[$this->options['expiry_field']]); - $this->assertGreaterThanOrEqual($expectedExpiry, round((string) $data[$this->options['expiry_field']] / 1000)); - }); + public function testReadExpired() + { + $this->insertSession('foo', 'bar', -100_000); + $this->assertEquals('', $this->storage->read('foo')); + } + public function testWrite() + { + $expectedTime = (new \DateTimeImmutable())->getTimestamp(); + $expectedExpiry = $expectedTime + (int) \ini_get('session.gc_maxlifetime'); $this->assertTrue($this->storage->write('foo', 'bar')); + + $sessions = $this->getSessions(); + $this->assertCount(1, $sessions); + $this->assertEquals('foo', $sessions[0]->_id); + $this->assertInstanceOf(Binary::class, $sessions[0]->data); + $this->assertEquals('bar', $sessions[0]->data->getData()); + $this->assertInstanceOf(UTCDateTime::class, $sessions[0]->time); + $this->assertGreaterThanOrEqual($expectedTime, round((string) $sessions[0]->time / 1000)); + $this->assertInstanceOf(UTCDateTime::class, $sessions[0]->expires_at); + $this->assertGreaterThanOrEqual($expectedExpiry, round((string) $sessions[0]->expires_at / 1000)); } public function testReplaceSessionData() { - $collection = $this->createMongoCollectionMock(); - - $this->mongo->expects($this->once()) - ->method('selectCollection') - ->with($this->options['database'], $this->options['collection']) - ->willReturn($collection); - - $data = []; - - $collection->expects($this->exactly(2)) - ->method('updateOne') - ->willReturnCallback(function ($criteria, $updateData, $options) use (&$data) { - $data = $updateData; - }); - $this->storage->write('foo', 'bar'); + $this->storage->write('baz', 'qux'); $this->storage->write('foo', 'foobar'); - $this->assertEquals('foobar', $data['$set'][$this->options['data_field']]->getData()); + $sessions = $this->getSessions(); + $this->assertCount(2, $sessions); + $this->assertEquals('foobar', $sessions[0]->data->getData()); } public function testDestroy() { - $collection = $this->createMongoCollectionMock(); - - $this->mongo->expects($this->once()) - ->method('selectCollection') - ->with($this->options['database'], $this->options['collection']) - ->willReturn($collection); + $this->storage->write('foo', 'bar'); + $this->storage->write('baz', 'qux'); - $collection->expects($this->once()) - ->method('deleteOne') - ->with([$this->options['id_field'] => 'foo']); + $this->storage->open('test', 'test'); $this->assertTrue($this->storage->destroy('foo')); + + $sessions = $this->getSessions(); + $this->assertCount(1, $sessions); + $this->assertEquals('baz', $sessions[0]->_id); } public function testGc() { - $collection = $this->createMongoCollectionMock(); - - $this->mongo->expects($this->once()) - ->method('selectCollection') - ->with($this->options['database'], $this->options['collection']) - ->willReturn($collection); - - $collection->expects($this->once()) - ->method('deleteMany') - ->willReturnCallback(function ($criteria) { - $this->assertInstanceOf(\MongoDB\BSON\UTCDateTime::class, $criteria[$this->options['expiry_field']]['$lt']); - $this->assertGreaterThanOrEqual(time() - 1, round((string) $criteria[$this->options['expiry_field']]['$lt'] / 1000)); + $this->insertSession('foo', 'bar', -100_000); + $this->insertSession('bar', 'bar', -100_000); + $this->insertSession('qux', 'bar', -300); + $this->insertSession('baz', 'bar', 0); - $result = $this->createMock(\MongoDB\DeleteResult::class); - $result->method('getDeletedCount')->willReturn(42); - - return $result; - }); - - $this->assertSame(42, $this->storage->gc(1)); + $this->assertSame(2, $this->storage->gc(1)); + $this->assertCount(2, $this->getSessions()); } - public function testGetConnection() + /** + * @return list + */ + private function getSessions(): array { - $method = new \ReflectionMethod($this->storage, 'getMongo'); - - $this->assertInstanceOf(Client::class, $method->invoke($this->storage)); + return $this->manager->executeQuery(self::DABASE_NAME.'.'.self::COLLECTION_NAME, new Query([]))->toArray(); } - private function createMongoCollectionMock(): \MongoDB\Collection + private function insertSession(string $sessionId, string $data, int $timeDiff): void { - $collection = $this->getMockBuilder(\MongoDB\Collection::class) - ->disableOriginalConstructor() - ->getMock(); + $time = time() + $timeDiff; + + $write = new BulkWrite(); + $write->insert([ + '_id' => $sessionId, + 'data' => new Binary($data, Binary::TYPE_GENERIC), + 'time' => new UTCDateTime($time * 1000), + 'expires_at' => new UTCDateTime(($time + (int) \ini_get('session.gc_maxlifetime')) * 1000), + ]); - return $collection; + $this->manager->executeBulkWrite(self::DABASE_NAME.'.'.self::COLLECTION_NAME, $write); } } diff --git a/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php b/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php index eb6842a5e..55a238732 100644 --- a/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/PdoSessionHandlerTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler; +use Doctrine\DBAL\Schema\Schema; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Session\Storage\Handler\PdoSessionHandler; @@ -21,7 +22,7 @@ */ class PdoSessionHandlerTest extends TestCase { - private $dbFile; + private ?string $dbFile = null; protected function tearDown(): void { @@ -156,9 +157,7 @@ public function testReadLockedConvertsStreamToString() $selectStmt = $this->createMock(\PDOStatement::class); $insertStmt = $this->createMock(\PDOStatement::class); - $pdo->prepareResult = function ($statement) use ($selectStmt, $insertStmt) { - return str_starts_with($statement, 'INSERT') ? $insertStmt : $selectStmt; - }; + $pdo->prepareResult = fn ($statement) => str_starts_with($statement, 'INSERT') ? $insertStmt : $selectStmt; $content = 'foobar'; $stream = $this->createStream($content); @@ -225,6 +224,7 @@ public function testWrongUsageStillWorks() { // wrong method sequence that should no happen, but still works $storage = new PdoSessionHandler($this->getMemorySqlitePdo()); + $storage->open('', 'sid'); $storage->write('id', 'data'); $storage->write('other_id', 'other_data'); $storage->destroy('inexistent'); @@ -327,6 +327,35 @@ public function testUrlDsn($url, $expectedDsn, $expectedUser = null, $expectedPa } } + public function testConfigureSchemaDifferentDatabase() + { + $schema = new Schema(); + + $pdoSessionHandler = new PdoSessionHandler($this->getMemorySqlitePdo()); + $pdoSessionHandler->configureSchema($schema, fn () => false); + $this->assertFalse($schema->hasTable('sessions')); + } + + public function testConfigureSchemaSameDatabase() + { + $schema = new Schema(); + + $pdoSessionHandler = new PdoSessionHandler($this->getMemorySqlitePdo()); + $pdoSessionHandler->configureSchema($schema, fn () => true); + $this->assertTrue($schema->hasTable('sessions')); + } + + public function testConfigureSchemaTableExistsPdo() + { + $schema = new Schema(); + $schema->createTable('sessions'); + + $pdoSessionHandler = new PdoSessionHandler($this->getMemorySqlitePdo()); + $pdoSessionHandler->configureSchema($schema, fn () => true); + $table = $schema->getTable('sessions'); + $this->assertEmpty($table->getColumns(), 'The table was not overwritten'); + } + public static function provideUrlDsnPairs() { yield ['mysql://localhost/test', 'mysql:host=localhost;dbname=test;']; @@ -376,11 +405,11 @@ private function createStream($content) class MockPdo extends \PDO { - public $prepareResult; - private $driverName; - private $errorMode; + public \Closure|\PDOStatement|false $prepareResult; + private ?string $driverName; + private bool|int $errorMode; - public function __construct(string $driverName = null, int $errorMode = null) + public function __construct(?string $driverName = null, ?int $errorMode = null) { $this->driverName = $driverName; $this->errorMode = null !== $errorMode ?: \PDO::ERRMODE_EXCEPTION; diff --git a/Tests/Session/Storage/Handler/PredisClusterSessionHandlerTest.php b/Tests/Session/Storage/Handler/PredisClusterSessionHandlerTest.php index fd4a13bef..492487766 100644 --- a/Tests/Session/Storage/Handler/PredisClusterSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/PredisClusterSessionHandlerTest.php @@ -20,6 +20,9 @@ class PredisClusterSessionHandlerTest extends AbstractRedisSessionHandlerTestCas { protected function createRedisClient(string $host): Client { - return new Client([array_combine(['host', 'port'], explode(':', getenv('REDIS_HOST')) + [1 => 6379])]); + return new Client( + [array_combine(['host', 'port'], explode(':', getenv('REDIS_HOST')) + [1 => 6379])], + ['cluster' => 'redis'] + ); } } diff --git a/Tests/Session/Storage/Handler/RedisClusterSessionHandlerTest.php b/Tests/Session/Storage/Handler/RedisClusterSessionHandlerTest.php index 031629501..6a30f558f 100644 --- a/Tests/Session/Storage/Handler/RedisClusterSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/RedisClusterSessionHandlerTest.php @@ -11,8 +11,6 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler; -use PHPUnit\Framework\SkippedTestSuiteError; - /** * @group integration */ @@ -21,11 +19,11 @@ class RedisClusterSessionHandlerTest extends AbstractRedisSessionHandlerTestCase public static function setUpBeforeClass(): void { if (!class_exists(\RedisCluster::class)) { - throw new SkippedTestSuiteError('The RedisCluster class is required.'); + self::markTestSkipped('The RedisCluster class is required.'); } if (!$hosts = getenv('REDIS_CLUSTER_HOSTS')) { - throw new SkippedTestSuiteError('REDIS_CLUSTER_HOSTS env var is not defined.'); + self::markTestSkipped('REDIS_CLUSTER_HOSTS env var is not defined.'); } } diff --git a/Tests/Session/Storage/Handler/RelaySessionHandlerTest.php b/Tests/Session/Storage/Handler/RelaySessionHandlerTest.php new file mode 100644 index 000000000..76553f96d --- /dev/null +++ b/Tests/Session/Storage/Handler/RelaySessionHandlerTest.php @@ -0,0 +1,28 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Session\Storage\Handler; + +use Relay\Relay; +use Symfony\Component\HttpFoundation\Tests\Session\Storage\Handler\AbstractRedisSessionHandlerTestCase; + +/** + * @requires extension relay + * + * @group integration + */ +class RelaySessionHandlerTest extends AbstractRedisSessionHandlerTestCase +{ + protected function createRedisClient(string $host): Relay + { + return new Relay(...explode(':', $host)); + } +} diff --git a/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php b/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php index e91f06a92..41699cf56 100644 --- a/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php +++ b/Tests/Session/Storage/Handler/SessionHandlerFactoryTest.php @@ -73,7 +73,7 @@ public function testCreateRedisHandlerFromDsn() $ttlProperty = $reflection->getProperty('ttl'); $this->assertSame(3600, $ttlProperty->getValue($handler)); - $handler = SessionHandlerFactory::createHandler('redis://localhost?prefix=foo&ttl=3600&ignored=bar', ['ttl' => function () { return 123; }]); + $handler = SessionHandlerFactory::createHandler('redis://localhost?prefix=foo&ttl=3600&ignored=bar', ['ttl' => fn () => 123]); $this->assertInstanceOf(\Closure::class, $reflection->getProperty('ttl')->getValue($handler)); } diff --git a/Tests/Session/Storage/Handler/StrictSessionHandlerTest.php b/Tests/Session/Storage/Handler/StrictSessionHandlerTest.php index 68db5f4cf..27c952cd2 100644 --- a/Tests/Session/Storage/Handler/StrictSessionHandlerTest.php +++ b/Tests/Session/Storage/Handler/StrictSessionHandlerTest.php @@ -130,6 +130,7 @@ public function testWriteEmptyNewSession() $handler->expects($this->never())->method('write'); $handler->expects($this->once())->method('destroy')->willReturn(true); $proxy = new StrictSessionHandler($handler); + $proxy->open('path', 'name'); $this->assertFalse($proxy->validateId('id')); $this->assertSame('', $proxy->read('id')); @@ -144,6 +145,7 @@ public function testWriteEmptyExistingSession() $handler->expects($this->never())->method('write'); $handler->expects($this->once())->method('destroy')->willReturn(true); $proxy = new StrictSessionHandler($handler); + $proxy->open('path', 'name'); $this->assertSame('data', $proxy->read('id')); $this->assertTrue($proxy->write('id', '')); @@ -155,6 +157,7 @@ public function testDestroy() $handler->expects($this->once())->method('destroy') ->with('id')->willReturn(true); $proxy = new StrictSessionHandler($handler); + $proxy->open('path', 'name'); $this->assertTrue($proxy->destroy('id')); } @@ -166,6 +169,7 @@ public function testDestroyNewSession() ->with('id')->willReturn(''); $handler->expects($this->once())->method('destroy')->willReturn(true); $proxy = new StrictSessionHandler($handler); + $proxy->open('path', 'name'); $this->assertSame('', $proxy->read('id')); $this->assertTrue($proxy->destroy('id')); @@ -181,6 +185,7 @@ public function testDestroyNonEmptyNewSession() $handler->expects($this->once())->method('destroy') ->with('id')->willReturn(true); $proxy = new StrictSessionHandler($handler); + $proxy->open('path', 'name'); $this->assertSame('', $proxy->read('id')); $this->assertTrue($proxy->write('id', 'data')); diff --git a/Tests/Session/Storage/Handler/stubs/mongodb.php b/Tests/Session/Storage/Handler/stubs/mongodb.php new file mode 100644 index 000000000..2cc31d55c --- /dev/null +++ b/Tests/Session/Storage/Handler/stubs/mongodb.php @@ -0,0 +1,24 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace MongoDB; + +use MongoDB\Driver\Manager; + +/* + * Stubs for the mongodb/mongodb library version ~1.16 + */ +if (!class_exists(Client::class, false)) { + abstract class Client + { + abstract public function getManager(): Manager; + } +} diff --git a/Tests/Session/Storage/MetadataBagTest.php b/Tests/Session/Storage/MetadataBagTest.php index 51a1b6472..b2f3de42b 100644 --- a/Tests/Session/Storage/MetadataBagTest.php +++ b/Tests/Session/Storage/MetadataBagTest.php @@ -21,12 +21,8 @@ */ class MetadataBagTest extends TestCase { - /** - * @var MetadataBag - */ - protected $bag; - - protected $array = []; + protected MetadataBag $bag; + protected array $array = []; protected function setUp(): void { @@ -36,13 +32,6 @@ protected function setUp(): void $this->bag->initialize($this->array); } - protected function tearDown(): void - { - $this->array = []; - $this->bag = null; - parent::tearDown(); - } - public function testInitialize() { $sessionMetadata = []; diff --git a/Tests/Session/Storage/MockArraySessionStorageTest.php b/Tests/Session/Storage/MockArraySessionStorageTest.php index 2428e9fc2..0fc991058 100644 --- a/Tests/Session/Storage/MockArraySessionStorageTest.php +++ b/Tests/Session/Storage/MockArraySessionStorageTest.php @@ -23,22 +23,10 @@ */ class MockArraySessionStorageTest extends TestCase { - /** - * @var MockArraySessionStorage - */ - private $storage; - - /** - * @var AttributeBag - */ - private $attributes; - - /** - * @var FlashBag - */ - private $flashes; - - private $data; + private MockArraySessionStorage $storage; + private AttributeBag $attributes; + private FlashBag $flashes; + private array $data; protected function setUp(): void { @@ -56,14 +44,6 @@ protected function setUp(): void $this->storage->setSessionData($this->data); } - protected function tearDown(): void - { - $this->data = null; - $this->flashes = null; - $this->attributes = null; - $this->storage = null; - } - public function testStart() { $this->assertEquals('', $this->storage->getId()); diff --git a/Tests/Session/Storage/MockFileSessionStorageTest.php b/Tests/Session/Storage/MockFileSessionStorageTest.php index 3f994cb2a..61804c268 100644 --- a/Tests/Session/Storage/MockFileSessionStorageTest.php +++ b/Tests/Session/Storage/MockFileSessionStorageTest.php @@ -23,15 +23,9 @@ */ class MockFileSessionStorageTest extends TestCase { - /** - * @var string - */ - private $sessionDir; + protected MockFileSessionStorage $storage; - /** - * @var MockFileSessionStorage - */ - protected $storage; + private string $sessionDir; protected function setUp(): void { @@ -45,8 +39,6 @@ protected function tearDown(): void if (is_dir($this->sessionDir)) { @rmdir($this->sessionDir); } - $this->sessionDir = null; - $this->storage = null; } public function testStart() diff --git a/Tests/Session/Storage/NativeSessionStorageTest.php b/Tests/Session/Storage/NativeSessionStorageTest.php index 9234e2b40..a0d54deb7 100644 --- a/Tests/Session/Storage/NativeSessionStorageTest.php +++ b/Tests/Session/Storage/NativeSessionStorageTest.php @@ -32,12 +32,16 @@ */ class NativeSessionStorageTest extends TestCase { - private $savePath; + private string $savePath; + + private $initialSessionSaveHandler; + private $initialSessionSavePath; protected function setUp(): void { - $this->iniSet('session.save_handler', 'files'); - $this->iniSet('session.save_path', $this->savePath = sys_get_temp_dir().'/sftest'); + $this->initialSessionSaveHandler = ini_set('session.save_handler', 'files'); + $this->initialSessionSavePath = ini_set('session.save_path', $this->savePath = sys_get_temp_dir().'/sftest'); + if (!is_dir($this->savePath)) { mkdir($this->savePath); } @@ -51,7 +55,8 @@ protected function tearDown(): void @rmdir($this->savePath); } - $this->savePath = null; + ini_set('session.save_handler', $this->initialSessionSaveHandler); + ini_set('session.save_path', $this->initialSessionSavePath); } protected function getStorage(array $options = []): NativeSessionStorage @@ -154,18 +159,26 @@ public function testRegenerationFailureDoesNotFlagStorageAsStarted() public function testDefaultSessionCacheLimiter() { - $this->iniSet('session.cache_limiter', 'nocache'); + $initialLimiter = ini_set('session.cache_limiter', 'nocache'); - new NativeSessionStorage(); - $this->assertEquals('', \ini_get('session.cache_limiter')); + try { + new NativeSessionStorage(); + $this->assertEquals('', \ini_get('session.cache_limiter')); + } finally { + ini_set('session.cache_limiter', $initialLimiter); + } } public function testExplicitSessionCacheLimiter() { - $this->iniSet('session.cache_limiter', 'nocache'); + $initialLimiter = ini_set('session.cache_limiter', 'nocache'); - new NativeSessionStorage(['cache_limiter' => 'public']); - $this->assertEquals('public', \ini_get('session.cache_limiter')); + try { + new NativeSessionStorage(['cache_limiter' => 'public']); + $this->assertEquals('public', \ini_get('session.cache_limiter')); + } finally { + ini_set('session.cache_limiter', $initialLimiter); + } } public function testCookieOptions() @@ -190,33 +203,58 @@ public function testCookieOptions() $this->assertEquals($options, $gco); } - public function testSessionOptions() + public function testCacheExpireOption() { $options = [ - 'trans_sid_tags' => 'a=href', 'cache_expire' => '200', ]; $this->getStorage($options); - $this->assertSame('a=href', \ini_get('session.trans_sid_tags')); $this->assertSame('200', \ini_get('session.cache_expire')); } + /** + * The test must only be removed when the "session.trans_sid_tags" option is removed from PHP or when the "trans_sid_tags" option is no longer supported by the native session storage. + */ + public function testTransSidTagsOption() + { + $previousErrorHandler = set_error_handler(function ($errno, $errstr) use (&$previousErrorHandler) { + if ('ini_set(): Usage of session.trans_sid_tags INI setting is deprecated' !== $errstr) { + return $previousErrorHandler ? $previousErrorHandler(...\func_get_args()) : false; + } + }); + + try { + $this->getStorage([ + 'trans_sid_tags' => 'a=href', + ]); + } finally { + restore_error_handler(); + } + + $this->assertSame('a=href', \ini_get('session.trans_sid_tags')); + } + public function testSetSaveHandler() { - $this->iniSet('session.save_handler', 'files'); - $storage = $this->getStorage(); - $storage->setSaveHandler(null); - $this->assertInstanceOf(SessionHandlerProxy::class, $storage->getSaveHandler()); - $storage->setSaveHandler(new SessionHandlerProxy(new NativeFileSessionHandler())); - $this->assertInstanceOf(SessionHandlerProxy::class, $storage->getSaveHandler()); - $storage->setSaveHandler(new NativeFileSessionHandler()); - $this->assertInstanceOf(SessionHandlerProxy::class, $storage->getSaveHandler()); - $storage->setSaveHandler(new SessionHandlerProxy(new NullSessionHandler())); - $this->assertInstanceOf(SessionHandlerProxy::class, $storage->getSaveHandler()); - $storage->setSaveHandler(new NullSessionHandler()); - $this->assertInstanceOf(SessionHandlerProxy::class, $storage->getSaveHandler()); + $initialSaveHandler = ini_set('session.save_handler', 'files'); + + try { + $storage = $this->getStorage(); + $storage->setSaveHandler(null); + $this->assertInstanceOf(SessionHandlerProxy::class, $storage->getSaveHandler()); + $storage->setSaveHandler(new SessionHandlerProxy(new NativeFileSessionHandler())); + $this->assertInstanceOf(SessionHandlerProxy::class, $storage->getSaveHandler()); + $storage->setSaveHandler(new NativeFileSessionHandler()); + $this->assertInstanceOf(SessionHandlerProxy::class, $storage->getSaveHandler()); + $storage->setSaveHandler(new SessionHandlerProxy(new NullSessionHandler())); + $this->assertInstanceOf(SessionHandlerProxy::class, $storage->getSaveHandler()); + $storage->setSaveHandler(new NullSessionHandler()); + $this->assertInstanceOf(SessionHandlerProxy::class, $storage->getSaveHandler()); + } finally { + ini_set('session.save_handler', $initialSaveHandler); + } } public function testStarted() diff --git a/Tests/Session/Storage/PhpBridgeSessionStorageTest.php b/Tests/Session/Storage/PhpBridgeSessionStorageTest.php index e2fb93ebc..5fbc38335 100644 --- a/Tests/Session/Storage/PhpBridgeSessionStorageTest.php +++ b/Tests/Session/Storage/PhpBridgeSessionStorageTest.php @@ -28,12 +28,16 @@ */ class PhpBridgeSessionStorageTest extends TestCase { - private $savePath; + private string $savePath; + + private $initialSessionSaveHandler; + private $initialSessionSavePath; protected function setUp(): void { - $this->iniSet('session.save_handler', 'files'); - $this->iniSet('session.save_path', $this->savePath = sys_get_temp_dir().'/sftest'); + $this->initialSessionSaveHandler = ini_set('session.save_handler', 'files'); + $this->initialSessionSavePath = ini_set('session.save_path', $this->savePath = sys_get_temp_dir().'/sftest'); + if (!is_dir($this->savePath)) { mkdir($this->savePath); } @@ -47,7 +51,8 @@ protected function tearDown(): void @rmdir($this->savePath); } - $this->savePath = null; + ini_set('session.save_handler', $this->initialSessionSaveHandler); + ini_set('session.save_path', $this->initialSessionSavePath); } protected function getStorage(): PhpBridgeSessionStorage diff --git a/Tests/Session/Storage/Proxy/AbstractProxyTest.php b/Tests/Session/Storage/Proxy/AbstractProxyTest.php index fde7a4a0a..bb459bb9f 100644 --- a/Tests/Session/Storage/Proxy/AbstractProxyTest.php +++ b/Tests/Session/Storage/Proxy/AbstractProxyTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Proxy; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Session\Storage\Proxy\AbstractProxy; use Symfony\Component\HttpFoundation\Session\Storage\Proxy\SessionHandlerProxy; @@ -22,19 +23,11 @@ */ class AbstractProxyTest extends TestCase { - /** - * @var AbstractProxy - */ - protected $proxy; + protected AbstractProxy $proxy; protected function setUp(): void { - $this->proxy = $this->getMockForAbstractClass(AbstractProxy::class); - } - - protected function tearDown(): void - { - $this->proxy = null; + $this->proxy = new class() extends AbstractProxy {}; } public function testGetSaveHandlerName() diff --git a/Tests/Session/Storage/Proxy/SessionHandlerProxyTest.php b/Tests/Session/Storage/Proxy/SessionHandlerProxyTest.php index eed23fe0b..d9c4974ef 100644 --- a/Tests/Session/Storage/Proxy/SessionHandlerProxyTest.php +++ b/Tests/Session/Storage/Proxy/SessionHandlerProxyTest.php @@ -11,6 +11,7 @@ namespace Symfony\Component\HttpFoundation\Tests\Session\Storage\Proxy; +use PHPUnit\Framework\MockObject\MockObject; use PHPUnit\Framework\TestCase; use Symfony\Component\HttpFoundation\Session\Storage\Handler\StrictSessionHandler; use Symfony\Component\HttpFoundation\Session\Storage\NativeSessionStorage; @@ -27,15 +28,9 @@ */ class SessionHandlerProxyTest extends TestCase { - /** - * @var \PHPUnit\Framework\MockObject\Matcher - */ - private $mock; + private MockObject&\SessionHandlerInterface $mock; - /** - * @var SessionHandlerProxy - */ - private $proxy; + private SessionHandlerProxy $proxy; protected function setUp(): void { @@ -43,12 +38,6 @@ protected function setUp(): void $this->proxy = new SessionHandlerProxy($this->mock); } - protected function tearDown(): void - { - $this->mock = null; - $this->proxy = null; - } - public function testOpenTrue() { $this->mock->expects($this->once()) diff --git a/Tests/StreamedJsonResponseTest.php b/Tests/StreamedJsonResponseTest.php new file mode 100644 index 000000000..db76cd3ae --- /dev/null +++ b/Tests/StreamedJsonResponseTest.php @@ -0,0 +1,271 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\StreamedJsonResponse; + +class StreamedJsonResponseTest extends TestCase +{ + public function testResponseSimpleList() + { + $content = $this->createSendResponse( + [ + '_embedded' => [ + 'articles' => $this->generatorSimple('Article'), + 'news' => $this->generatorSimple('News'), + ], + ], + ); + + $this->assertSame('{"_embedded":{"articles":["Article 1","Article 2","Article 3"],"news":["News 1","News 2","News 3"]}}', $content); + } + + public function testResponseSimpleGenerator() + { + $content = $this->createSendResponse($this->generatorSimple('Article')); + + $this->assertSame('["Article 1","Article 2","Article 3"]', $content); + } + + public function testResponseNestedGenerator() + { + $content = $this->createSendResponse((function (): iterable { + yield 'articles' => $this->generatorSimple('Article'); + yield 'news' => $this->generatorSimple('News'); + })()); + + $this->assertSame('{"articles":["Article 1","Article 2","Article 3"],"news":["News 1","News 2","News 3"]}', $content); + } + + public function testResponseEmptyList() + { + $content = $this->createSendResponse( + [ + '_embedded' => [ + 'articles' => $this->generatorSimple('Article', 0), + ], + ], + ); + + $this->assertSame('{"_embedded":{"articles":[]}}', $content); + } + + public function testResponseObjectsList() + { + $content = $this->createSendResponse( + [ + '_embedded' => [ + 'articles' => $this->generatorArray('Article'), + ], + ], + ); + + $this->assertSame('{"_embedded":{"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}}', $content); + } + + public function testResponseWithoutGenerator() + { + // while it is not the intended usage, all kind of iterables should be supported for good DX + $content = $this->createSendResponse( + [ + '_embedded' => [ + 'articles' => ['Article 1', 'Article 2', 'Article 3'], + ], + ], + ); + + $this->assertSame('{"_embedded":{"articles":["Article 1","Article 2","Article 3"]}}', $content); + } + + public function testResponseWithPlaceholder() + { + // the placeholder must not conflict with generator injection + $content = $this->createSendResponse( + [ + '_embedded' => [ + 'articles' => $this->generatorArray('Article'), + 'placeholder' => '__symfony_json__', + 'news' => $this->generatorSimple('News'), + ], + 'placeholder' => '__symfony_json__', + ], + ); + + $this->assertSame('{"_embedded":{"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}],"placeholder":"__symfony_json__","news":["News 1","News 2","News 3"]},"placeholder":"__symfony_json__"}', $content); + } + + public function testResponseWithMixedKeyType() + { + $content = $this->createSendResponse( + [ + '_embedded' => [ + 'list' => (function (): \Generator { + yield 0 => 'test'; + yield 'key' => 'value'; + })(), + 'map' => (function (): \Generator { + yield 'key' => 'value'; + yield 0 => 'test'; + })(), + 'integer' => (function (): \Generator { + yield 1 => 'one'; + yield 3 => 'three'; + })(), + ], + ] + ); + + $this->assertSame('{"_embedded":{"list":["test","value"],"map":{"key":"value","0":"test"},"integer":{"1":"one","3":"three"}}}', $content); + } + + public function testResponseOtherTraversable() + { + $arrayObject = new \ArrayObject(['__symfony_json__' => '__symfony_json__']); + + $iteratorAggregate = new class() implements \IteratorAggregate { + public function getIterator(): \Traversable + { + return new \ArrayIterator(['__symfony_json__']); + } + }; + + $jsonSerializable = new class() implements \IteratorAggregate, \JsonSerializable { + public function getIterator(): \Traversable + { + return new \ArrayIterator(['This should be ignored']); + } + + public function jsonSerialize(): mixed + { + return ['__symfony_json__' => '__symfony_json__']; + } + }; + + // while Generators should be used for performance reasons, the object should also work with any Traversable + // to make things easier for a developer + $content = $this->createSendResponse( + [ + 'arrayObject' => $arrayObject, + 'iteratorAggregate' => $iteratorAggregate, + 'jsonSerializable' => $jsonSerializable, + // add a Generator to make sure it still work in combination with other Traversable objects + 'articles' => $this->generatorArray('Article'), + ], + ); + + $this->assertSame('{"arrayObject":{"__symfony_json__":"__symfony_json__"},"iteratorAggregate":["__symfony_json__"],"jsonSerializable":{"__symfony_json__":"__symfony_json__"},"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}', $content); + } + + public function testPlaceholderAsKeyAndValueInStructure() + { + $content = $this->createSendResponse( + [ + '__symfony_json__' => '__symfony_json__', + 'articles' => $this->generatorArray('Article'), + ], + ); + + $this->assertSame('{"__symfony_json__":"__symfony_json__","articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}', $content); + } + + public function testResponseStatusCode() + { + $response = new StreamedJsonResponse([], 201); + + $this->assertSame(201, $response->getStatusCode()); + } + + public function testPlaceholderAsObjectStructure() + { + $object = new class() { + public $__symfony_json__ = 'foo'; + public $bar = '__symfony_json__'; + }; + + $content = $this->createSendResponse( + [ + 'object' => $object, + // add a Generator to make sure it still work in combination with other object holding placeholders + 'articles' => $this->generatorArray('Article'), + ], + ); + + $this->assertSame('{"object":{"__symfony_json__":"foo","bar":"__symfony_json__"},"articles":[{"title":"Article 1"},{"title":"Article 2"},{"title":"Article 3"}]}', $content); + } + + public function testResponseHeaders() + { + $response = new StreamedJsonResponse([], 200, ['X-Test' => 'Test']); + + $this->assertSame('Test', $response->headers->get('X-Test')); + } + + public function testCustomContentType() + { + $response = new StreamedJsonResponse([], 200, ['Content-Type' => 'application/json+stream']); + + $this->assertSame('application/json+stream', $response->headers->get('Content-Type')); + } + + public function testEncodingOptions() + { + $response = new StreamedJsonResponse([ + '_embedded' => [ + 'count' => '2', // options are applied to the initial json encode + 'values' => (function (): \Generator { + yield 'with/unescaped/slash' => 'With/a/slash'; // options are applied to key and values + yield '3' => '3'; // numeric check for value, but not for the key + })(), + ], + ], encodingOptions: \JSON_UNESCAPED_SLASHES | \JSON_NUMERIC_CHECK); + + ob_start(); + $response->send(); + $content = ob_get_clean(); + + $this->assertSame('{"_embedded":{"count":2,"values":{"with/unescaped/slash":"With/a/slash","3":3}}}', $content); + } + + /** + * @param iterable $data + */ + private function createSendResponse(iterable $data): string + { + $response = new StreamedJsonResponse($data); + + ob_start(); + $response->send(); + + return ob_get_clean(); + } + + /** + * @return \Generator + */ + private function generatorSimple(string $test, int $length = 3): \Generator + { + for ($i = 1; $i <= $length; ++$i) { + yield $test.' '.$i; + } + } + + /** + * @return \Generator + */ + private function generatorArray(string $test, int $length = 3): \Generator + { + for ($i = 1; $i <= $length; ++$i) { + yield ['title' => $test.' '.$i]; + } + } +} diff --git a/Tests/StreamedResponseTest.php b/Tests/StreamedResponseTest.php index 1ca1bb92a..2a2b7e731 100644 --- a/Tests/StreamedResponseTest.php +++ b/Tests/StreamedResponseTest.php @@ -124,4 +124,15 @@ public function testSetNotModified() $string = ob_get_clean(); $this->assertEmpty($string); } + + public function testSendInformationalResponse() + { + $response = new StreamedResponse(); + $response->sendHeaders(103); + + // Informational responses must not override the main status code + $this->assertSame(200, $response->getStatusCode()); + + $response->sendHeaders(); + } } diff --git a/Tests/Test/Constraint/ResponseHeaderLocationSameTest.php b/Tests/Test/Constraint/ResponseHeaderLocationSameTest.php new file mode 100644 index 000000000..d05a9f879 --- /dev/null +++ b/Tests/Test/Constraint/ResponseHeaderLocationSameTest.php @@ -0,0 +1,137 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests\Test\Constraint; + +use PHPUnit\Framework\ExpectationFailedException; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Test\Constraint\ResponseHeaderLocationSame; + +class ResponseHeaderLocationSameTest extends TestCase +{ + /** + * @dataProvider provideSuccessCases + */ + public function testConstraintSuccess(string $requestUrl, ?string $location, string $expectedLocation) + { + $request = Request::create($requestUrl); + + $response = new Response(); + if (null !== $location) { + $response->headers->set('Location', $location); + } + + $constraint = new ResponseHeaderLocationSame($request, $expectedLocation); + + self::assertTrue($constraint->evaluate($response, '', true)); + } + + public static function provideSuccessCases(): iterable + { + yield ['http://example.com', 'http://example.com', 'http://example.com']; + yield ['http://example.com', 'http://example.com', '//example.com']; + yield ['http://example.com', 'http://example.com', '/']; + yield ['http://example.com', '//example.com', 'http://example.com']; + yield ['http://example.com', '//example.com', '//example.com']; + yield ['http://example.com', '//example.com', '/']; + yield ['http://example.com', '/', 'http://example.com']; + yield ['http://example.com', '/', '//example.com']; + yield ['http://example.com', '/', '/']; + + yield ['http://example.com/', 'http://example.com/', 'http://example.com/']; + yield ['http://example.com/', 'http://example.com/', '//example.com/']; + yield ['http://example.com/', 'http://example.com/', '/']; + yield ['http://example.com/', '//example.com/', 'http://example.com/']; + yield ['http://example.com/', '//example.com/', '//example.com/']; + yield ['http://example.com/', '//example.com/', '/']; + yield ['http://example.com/', '/', 'http://example.com/']; + yield ['http://example.com/', '/', '//example.com/']; + yield ['http://example.com/', '/', '/']; + + yield ['http://example.com/foo', 'http://example.com/', 'http://example.com/']; + yield ['http://example.com/foo', 'http://example.com/', '//example.com/']; + yield ['http://example.com/foo', 'http://example.com/', '/']; + yield ['http://example.com/foo', '//example.com/', 'http://example.com/']; + yield ['http://example.com/foo', '//example.com/', '//example.com/']; + yield ['http://example.com/foo', '//example.com/', '/']; + yield ['http://example.com/foo', '/', 'http://example.com/']; + yield ['http://example.com/foo', '/', '//example.com/']; + yield ['http://example.com/foo', '/', '/']; + + yield ['http://example.com/foo', 'http://example.com/bar', 'http://example.com/bar']; + yield ['http://example.com/foo', 'http://example.com/bar', '//example.com/bar']; + yield ['http://example.com/foo', 'http://example.com/bar', '/bar']; + yield ['http://example.com/foo', '//example.com/bar', 'http://example.com/bar']; + yield ['http://example.com/foo', '//example.com/bar', '//example.com/bar']; + yield ['http://example.com/foo', '//example.com/bar', '/bar']; + yield ['http://example.com/foo', '/bar', 'http://example.com/bar']; + yield ['http://example.com/foo', '/bar', '//example.com/bar']; + yield ['http://example.com/foo', '/bar', '/bar']; + + yield ['http://example.com', 'http://example.com/bar', 'http://example.com/bar']; + yield ['http://example.com', 'http://example.com/bar', '//example.com/bar']; + yield ['http://example.com', 'http://example.com/bar', '/bar']; + yield ['http://example.com', '//example.com/bar', 'http://example.com/bar']; + yield ['http://example.com', '//example.com/bar', '//example.com/bar']; + yield ['http://example.com', '//example.com/bar', '/bar']; + yield ['http://example.com', '/bar', 'http://example.com/bar']; + yield ['http://example.com', '/bar', '//example.com/bar']; + yield ['http://example.com', '/bar', '/bar']; + + yield ['http://example.com/', 'http://another-example.com', 'http://another-example.com']; + } + + /** + * @dataProvider provideFailureCases + */ + public function testConstraintFailure(string $requestUrl, ?string $location, string $expectedLocation) + { + $request = Request::create($requestUrl); + + $response = new Response(); + if (null !== $location) { + $response->headers->set('Location', $location); + } + + $constraint = new ResponseHeaderLocationSame($request, $expectedLocation); + + self::assertFalse($constraint->evaluate($response, '', true)); + + $this->expectException(ExpectationFailedException::class); + + $constraint->evaluate($response); + } + + public static function provideFailureCases(): iterable + { + yield ['http://example.com', null, 'http://example.com']; + yield ['http://example.com', null, '//example.com']; + yield ['http://example.com', null, '/']; + + yield ['http://example.com', 'http://another-example.com', 'http://example.com']; + yield ['http://example.com', 'http://another-example.com', '//example.com']; + yield ['http://example.com', 'http://another-example.com', '/']; + + yield ['http://example.com', 'http://example.com/bar', 'http://example.com']; + yield ['http://example.com', 'http://example.com/bar', '//example.com']; + yield ['http://example.com', 'http://example.com/bar', '/']; + + yield ['http://example.com/foo', 'http://example.com/bar', 'http://example.com']; + yield ['http://example.com/foo', 'http://example.com/bar', '//example.com']; + yield ['http://example.com/foo', 'http://example.com/bar', '/']; + + yield ['http://example.com/foo', 'http://example.com/bar', 'http://example.com/foo']; + yield ['http://example.com/foo', 'http://example.com/bar', '//example.com/foo']; + yield ['http://example.com/foo', 'http://example.com/bar', '/foo']; + } +} diff --git a/Tests/UriSignerTest.php b/Tests/UriSignerTest.php new file mode 100644 index 000000000..dfbe81e88 --- /dev/null +++ b/Tests/UriSignerTest.php @@ -0,0 +1,86 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation\Tests; + +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\UriSigner; + +class UriSignerTest extends TestCase +{ + public function testSign() + { + $signer = new UriSigner('foobar'); + + $this->assertStringContainsString('?_hash=', $signer->sign('http://example.com/foo')); + $this->assertStringContainsString('?_hash=', $signer->sign('http://example.com/foo?foo=bar')); + $this->assertStringContainsString('&foo=', $signer->sign('http://example.com/foo?foo=bar')); + } + + public function testCheck() + { + $signer = new UriSigner('foobar'); + + $this->assertFalse($signer->check('http://example.com/foo?_hash=foo')); + $this->assertFalse($signer->check('http://example.com/foo?foo=bar&_hash=foo')); + $this->assertFalse($signer->check('http://example.com/foo?foo=bar&_hash=foo&bar=foo')); + + $this->assertTrue($signer->check($signer->sign('http://example.com/foo'))); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar'))); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&0=integer'))); + + $this->assertSame($signer->sign('http://example.com/foo?foo=bar&bar=foo'), $signer->sign('http://example.com/foo?bar=foo&foo=bar')); + } + + public function testCheckWithDifferentArgSeparator() + { + $this->iniSet('arg_separator.output', '&'); + $signer = new UriSigner('foobar'); + + $this->assertSame( + 'http://example.com/foo?_hash=rIOcC%2FF3DoEGo%2FvnESjSp7uU9zA9S%2F%2BOLhxgMexoPUM%3D&baz=bay&foo=bar', + $signer->sign('http://example.com/foo?foo=bar&baz=bay') + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay'))); + } + + public function testCheckWithRequest() + { + $signer = new UriSigner('foobar'); + + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo')))); + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar')))); + $this->assertTrue($signer->checkRequest(Request::create($signer->sign('http://example.com/foo?foo=bar&0=integer')))); + } + + public function testCheckWithDifferentParameter() + { + $signer = new UriSigner('foobar', 'qux'); + + $this->assertSame( + 'http://example.com/foo?baz=bay&foo=bar&qux=rIOcC%2FF3DoEGo%2FvnESjSp7uU9zA9S%2F%2BOLhxgMexoPUM%3D', + $signer->sign('http://example.com/foo?foo=bar&baz=bay') + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?foo=bar&baz=bay'))); + } + + public function testSignerWorksWithFragments() + { + $signer = new UriSigner('foobar'); + + $this->assertSame( + 'http://example.com/foo?_hash=EhpAUyEobiM3QTrKxoLOtQq5IsWyWedoXDPqIjzNj5o%3D&bar=foo&foo=bar#foobar', + $signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar') + ); + $this->assertTrue($signer->check($signer->sign('http://example.com/foo?bar=foo&foo=bar#foobar'))); + } +} diff --git a/Tests/UrlHelperTest.php b/Tests/UrlHelperTest.php index 168f69efe..02f6c64cf 100644 --- a/Tests/UrlHelperTest.php +++ b/Tests/UrlHelperTest.php @@ -16,6 +16,7 @@ use Symfony\Component\HttpFoundation\RequestStack; use Symfony\Component\HttpFoundation\UrlHelper; use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\RequestContextAwareInterface; class UrlHelperTest extends TestCase { @@ -64,11 +65,44 @@ public function testGenerateAbsoluteUrlWithRequestContext($path, $baseUrl, $host } $requestContext = new RequestContext($baseUrl, 'GET', $host, $scheme, $httpPort, $httpsPort, $path); + $helper = new UrlHelper(new RequestStack(), $requestContext); $this->assertEquals($expected, $helper->getAbsoluteUrl($path)); } + /** + * @dataProvider getGenerateAbsoluteUrlRequestContextData + */ + public function testGenerateAbsoluteUrlWithRequestContextAwareInterface($path, $baseUrl, $host, $scheme, $httpPort, $httpsPort, $expected) + { + if (!class_exists(RequestContext::class)) { + $this->markTestSkipped('The Routing component is needed to run tests that depend on its request context.'); + } + + $requestContext = new RequestContext($baseUrl, 'GET', $host, $scheme, $httpPort, $httpsPort, $path); + $contextAware = new class($requestContext) implements RequestContextAwareInterface { + public function __construct( + private RequestContext $requestContext, + ) { + } + + public function setContext(RequestContext $context): void + { + $this->requestContext = $context; + } + + public function getContext(): RequestContext + { + return $this->requestContext; + } + }; + + $helper = new UrlHelper(new RequestStack(), $contextAware); + + $this->assertEquals($expected, $helper->getAbsoluteUrl($path)); + } + /** * @dataProvider getGenerateAbsoluteUrlRequestContextData */ diff --git a/UriSigner.php b/UriSigner.php new file mode 100644 index 000000000..b04987724 --- /dev/null +++ b/UriSigner.php @@ -0,0 +1,112 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\HttpFoundation; + +/** + * @author Fabien Potencier + */ +class UriSigner +{ + private string $secret; + private string $parameter; + + /** + * @param string $parameter Query string parameter to use + */ + public function __construct(#[\SensitiveParameter] string $secret, string $parameter = '_hash') + { + if (!$secret) { + throw new \InvalidArgumentException('A non-empty secret is required.'); + } + + $this->secret = $secret; + $this->parameter = $parameter; + } + + /** + * Signs a URI. + * + * The given URI is signed by adding the query string parameter + * which value depends on the URI and the secret. + */ + public function sign(string $uri): string + { + $url = parse_url($uri); + $params = []; + + if (isset($url['query'])) { + parse_str($url['query'], $params); + } + + $uri = $this->buildUrl($url, $params); + $params[$this->parameter] = $this->computeHash($uri); + + return $this->buildUrl($url, $params); + } + + /** + * Checks that a URI contains the correct hash. + */ + public function check(string $uri): bool + { + $url = parse_url($uri); + $params = []; + + if (isset($url['query'])) { + parse_str($url['query'], $params); + } + + if (empty($params[$this->parameter])) { + return false; + } + + $hash = $params[$this->parameter]; + unset($params[$this->parameter]); + + return hash_equals($this->computeHash($this->buildUrl($url, $params)), $hash); + } + + public function checkRequest(Request $request): bool + { + $qs = ($qs = $request->server->get('QUERY_STRING')) ? '?'.$qs : ''; + + // we cannot use $request->getUri() here as we want to work with the original URI (no query string reordering) + return $this->check($request->getSchemeAndHttpHost().$request->getBaseUrl().$request->getPathInfo().$qs); + } + + private function computeHash(string $uri): string + { + return base64_encode(hash_hmac('sha256', $uri, $this->secret, true)); + } + + private function buildUrl(array $url, array $params = []): string + { + ksort($params, \SORT_STRING); + $url['query'] = http_build_query($params, '', '&'); + + $scheme = isset($url['scheme']) ? $url['scheme'].'://' : ''; + $host = $url['host'] ?? ''; + $port = isset($url['port']) ? ':'.$url['port'] : ''; + $user = $url['user'] ?? ''; + $pass = isset($url['pass']) ? ':'.$url['pass'] : ''; + $pass = ($user || $pass) ? "$pass@" : ''; + $path = $url['path'] ?? ''; + $query = $url['query'] ? '?'.$url['query'] : ''; + $fragment = isset($url['fragment']) ? '#'.$url['fragment'] : ''; + + return $scheme.$user.$pass.$host.$port.$path.$query.$fragment; + } +} + +if (!class_exists(\Symfony\Component\HttpKernel\UriSigner::class, false)) { + class_alias(UriSigner::class, \Symfony\Component\HttpKernel\UriSigner::class); +} diff --git a/UrlHelper.php b/UrlHelper.php index 42fcf0459..f971cf662 100644 --- a/UrlHelper.php +++ b/UrlHelper.php @@ -12,6 +12,7 @@ namespace Symfony\Component\HttpFoundation; use Symfony\Component\Routing\RequestContext; +use Symfony\Component\Routing\RequestContextAwareInterface; /** * A helper service for manipulating URLs within and outside the request scope. @@ -20,13 +21,10 @@ */ final class UrlHelper { - private RequestStack $requestStack; - private ?RequestContext $requestContext; - - public function __construct(RequestStack $requestStack, RequestContext $requestContext = null) - { - $this->requestStack = $requestStack; - $this->requestContext = $requestContext; + public function __construct( + private RequestStack $requestStack, + private RequestContextAwareInterface|RequestContext|null $requestContext = null, + ) { } public function getAbsoluteUrl(string $path): string @@ -73,28 +71,36 @@ public function getRelativePath(string $path): string private function getAbsoluteUrlFromContext(string $path): string { - if (null === $this->requestContext || '' === $host = $this->requestContext->getHost()) { + if (null === $context = $this->requestContext) { + return $path; + } + + if ($context instanceof RequestContextAwareInterface) { + $context = $context->getContext(); + } + + if ('' === $host = $context->getHost()) { return $path; } - $scheme = $this->requestContext->getScheme(); + $scheme = $context->getScheme(); $port = ''; - if ('http' === $scheme && 80 !== $this->requestContext->getHttpPort()) { - $port = ':'.$this->requestContext->getHttpPort(); - } elseif ('https' === $scheme && 443 !== $this->requestContext->getHttpsPort()) { - $port = ':'.$this->requestContext->getHttpsPort(); + if ('http' === $scheme && 80 !== $context->getHttpPort()) { + $port = ':'.$context->getHttpPort(); + } elseif ('https' === $scheme && 443 !== $context->getHttpsPort()) { + $port = ':'.$context->getHttpsPort(); } if ('#' === $path[0]) { - $queryString = $this->requestContext->getQueryString(); - $path = $this->requestContext->getPathInfo().($queryString ? '?'.$queryString : '').$path; + $queryString = $context->getQueryString(); + $path = $context->getPathInfo().($queryString ? '?'.$queryString : '').$path; } elseif ('?' === $path[0]) { - $path = $this->requestContext->getPathInfo().$path; + $path = $context->getPathInfo().$path; } if ('/' !== $path[0]) { - $path = rtrim($this->requestContext->getBaseUrl(), '/').'/'.$path; + $path = rtrim($context->getBaseUrl(), '/').'/'.$path; } return $scheme.'://'.$host.$port.$path; diff --git a/composer.json b/composer.json index e333a23b7..be85696e5 100644 --- a/composer.json +++ b/composer.json @@ -17,23 +17,22 @@ ], "require": { "php": ">=8.1", - "symfony/deprecation-contracts": "^2.1|^3", - "symfony/polyfill-mbstring": "~1.1" + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" }, "require-dev": { - "predis/predis": "~1.0", - "symfony/cache": "^5.4|^6.0", - "symfony/dependency-injection": "^5.4|^6.0", - "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4", - "symfony/mime": "^5.4|^6.0", - "symfony/expression-language": "^5.4|^6.0", - "symfony/rate-limiter": "^5.2|^6.0" + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.3|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0" }, "conflict": { - "symfony/cache": "<6.2" - }, - "suggest" : { - "symfony/mime": "To use the file extension guesser" + "symfony/cache": "<6.3" }, "autoload": { "psr-4": { "Symfony\\Component\\HttpFoundation\\": "" }, diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 162056865..66c8c1836 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -10,6 +10,7 @@ > +