diff --git a/AGENTS.md b/AGENTS.md index 6c8f77f..030d678 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -252,6 +252,34 @@ every mutation of it is masked. `RequestReproducer::redactCase()` had four such guards over `$body['encoding']` and became one `match`; the escapes went with them. Masked mutants are a shape, not a fact of life. +The numeric-boundary wave (2026-09-18, #116) adds, all in +`ScalarArbitraries`: the overflow guard of `multiply()` is unreachable by +construction — `first`/`last` are the exact ceil/floor of `min`/`max` over the +multiple, so every index product lands inside `[min, max]` and cannot leave the +integer range; only its boundary flip (`<` → `<=` on +`intdiv(PHP_INT_MIN, $right)`) is observable, and +`integerMultiplesReachTheNativeMinimum` kills it. The `(float)` casts around +`$min / (float) $multiple` and in `round((float) $value * (float) $multiple)` +are redundant — PHP division always yields float, and arithmetic with one +float operand is float. The `(float)` casts on `PHP_INT_MIN`/`PHP_INT_MAX` in +`multipleIndex()` are exact (±2^63 needs no rounding), and flipping its first +`||` to `&&` changes nothing: NAN cannot reach the method (bounds and multiple +are finite), and ±INF is still caught by the remaining clauses. The endianness +probe of `adjacentFloat()` (`pack('d', 1.0)[0]`, `range(7, 0)`) only mutates +into answers that agree on the little-endian hardware CI runs on, and the +byte-loop budget variants (`range(0, 8)`-style) are reachable only through an +all-`0xff`/all-`0x00` double — a NaN, rejected upstream by `is_finite()`. The +carry itself is *not* equivalent and stays killed: +`exclusiveMinimumCarriesThroughTheMantissaBytes` and +`exclusiveMaximumOnAPositiveValueBorrowsThroughTheLowBytes` pin the +increment-carry and the decrement-borrow chains (a broken break-condition +returns a value on the wrong side of the input). The `Throw_` removals at the +native-limit exclusive guards survive a schema without the opposite bound — +`++$min` overflows to a float that the `min > max` guard rejects with the same +message — so the provider carries the both-bounds-at-the-limit cases that make +the overflow observable as a `TypeError` instead. + + ## The contract package is the other half of the oracle `rasuvaeff/openapi-contract` depends on nothing here, and this package depends diff --git a/CHANGELOG.md b/CHANGELOG.md index 492362a..1edb44b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## 0.14.1 — 2026-09-18 + +- **Fixed.** Valid number schemas now keep exclusive bounds at the adjacent + IEEE-754 value, including subnormals, instead of stepping by a decimal + fraction that could cross an entire representable interval. +- **Fixed.** Integer `multipleOf` calculations stay in the native integer + domain and fail closed at its limits; number multiples whose index cannot + fit that domain now fail closed too. +- **Fixed.** Multipart per-part content types and encoding headers reject + invalid field names and CR/LF values before either a case or a PSR-7 request + is constructed. + ## 0.14.0 — 2026-09-12 - **Changed.** Accepts `rasuvaeff/property-testing-core` `^0.10` alongside diff --git a/src/Internal/Compile/ScalarArbitraries.php b/src/Internal/Compile/ScalarArbitraries.php index 37eddb3..a5c0043 100644 --- a/src/Internal/Compile/ScalarArbitraries.php +++ b/src/Internal/Compile/ScalarArbitraries.php @@ -188,9 +188,17 @@ public function integer(array $schema): ArbitraryInterface $min = $this->facts->integerBound($schema, 'minimum', -1000); $max = $this->facts->integerBound($schema, 'maximum', 1000); if (($schema['exclusiveMinimum'] ?? false) === true) { + if ($min === PHP_INT_MAX) { + throw UnsupportedGeneration::forSchema('integer bounds leave no value'); + } + ++$min; } if (($schema['exclusiveMaximum'] ?? false) === true) { + if ($max === PHP_INT_MIN) { + throw UnsupportedGeneration::forSchema('integer bounds leave no value'); + } + --$max; } if ($min > $max) { @@ -204,14 +212,16 @@ public function integer(array $schema): ArbitraryInterface if (!is_int($multiple) || $multiple <= 0) { throw UnsupportedGeneration::forSchema('integer multipleOf must be a positive integer'); } - $multipleValue = (float) $multiple; - $first = (int) ceil((float) $min / $multipleValue); - $last = (int) floor((float) $max / $multipleValue); + $first = $this->ceilDiv($min, $multiple); + $last = $this->floorDiv($max, $multiple); if ($first > $last) { throw UnsupportedGeneration::forSchema('integer multipleOf leaves no value'); } - return Gen::map(Gen::intBetween($first, $last), static fn(mixed $value): int => (int) $value * $multiple); + return Gen::map( + Gen::intBetween($first, $last), + fn(mixed $value): int => $this->multiply((int) $value, $multiple), + ); } /** @param array $schema */ @@ -219,19 +229,16 @@ public function number(array $schema): ArbitraryInterface { $min = $this->facts->numberBound($schema, 'minimum', -1000.0); $max = $this->facts->numberBound($schema, 'maximum', 1000.0); + if (!is_finite($min) || !is_finite($max)) { + throw UnsupportedGeneration::forSchema('number bounds must be finite'); + } $exclusiveMinimum = ($schema['exclusiveMinimum'] ?? false) === true; $exclusiveMaximum = ($schema['exclusiveMaximum'] ?? false) === true; - if (($exclusiveMinimum || $exclusiveMaximum) && $min >= $max) { - throw UnsupportedGeneration::forSchema('number bounds leave no value'); - } - // Step inside an open bound by a tenth, or by a quarter of a narrow - // window, so that `(0, 0.05]` still leaves values. - $step = min(0.1, ($max - $min) / 4.0); if ($exclusiveMinimum) { - $min += $step; + $min = $this->nextUp($min); } if ($exclusiveMaximum) { - $max -= $step; + $max = $this->nextDown($max); } if ($min > $max) { throw UnsupportedGeneration::forSchema('number bounds leave no value'); @@ -247,8 +254,8 @@ public function number(array $schema): ArbitraryInterface if ($multiple <= 0 || !is_finite((float) $multiple)) { throw UnsupportedGeneration::forSchema('number multipleOf must be positive and finite'); } - $first = (int) ceil($min / (float) $multiple); - $last = (int) floor($max / (float) $multiple); + $first = $this->multipleIndex(ceil($min / (float) $multiple)); + $last = $this->multipleIndex(floor($max / (float) $multiple)); if ($first > $last) { throw UnsupportedGeneration::forSchema('number multipleOf leaves no value'); } @@ -264,4 +271,85 @@ public function number(array $schema): ArbitraryInterface static fn(mixed $value): float => round((float) $value * (float) $multiple, $decimals), ); } + + private function ceilDiv(int $dividend, int $divisor): int + { + $quotient = intdiv($dividend, $divisor); + + return $dividend > 0 && $dividend % $divisor !== 0 ? $quotient + 1 : $quotient; + } + + private function floorDiv(int $dividend, int $divisor): int + { + $quotient = intdiv($dividend, $divisor); + + return $dividend < 0 && $dividend % $divisor !== 0 ? $quotient - 1 : $quotient; + } + + private function multiply(int $left, int $right): int + { + if (($left > 0 && $left > intdiv(PHP_INT_MAX, $right)) + || ($left < 0 && $left < intdiv(PHP_INT_MIN, $right)) + ) { + throw UnsupportedGeneration::forSchema('integer multipleOf result is outside the integer range'); + } + + return $left * $right; + } + + private function multipleIndex(float $index): int + { + // PHP cannot represent PHP_INT_MAX as a float: the nearest double is + // PHP_INT_MAX + 1. Refuse that edge rather than cast it to PHP_INT_MIN. + if (!is_finite($index) || $index < (float) PHP_INT_MIN || $index >= (float) PHP_INT_MAX) { + throw UnsupportedGeneration::forSchema('number multipleOf index is outside the supported integer range'); + } + + return (int) $index; + } + + private function nextUp(float $value): float + { + return $this->adjacentFloat($value, towardPositive: true); + } + + private function nextDown(float $value): float + { + return $this->adjacentFloat($value, towardPositive: false); + } + + private function adjacentFloat(float $value, bool $towardPositive): float + { + if ($value === 0.0) { + return $towardPositive ? 5e-324 : -5e-324; + } + + $bits = pack('d', $value); + $littleEndian = ord(pack('d', 1.0)[0]) === 0; + $indices = $littleEndian ? range(0, 7) : range(7, 0); + $increment = ($value > 0.0) === $towardPositive; + + foreach ($indices as $index) { + $byte = ord($bits[$index]); + if ($increment) { + $bits[$index] = chr(($byte + 1) & 0xff); + if ($byte !== 0xff) { + break; + } + } else { + $bits[$index] = chr(($byte - 1) & 0xff); + if ($byte !== 0x00) { + break; + } + } + } + + $decoded = unpack('dvalue', $bits); + $adjacent = is_array($decoded) ? $decoded['value'] ?? null : null; + if (!is_float($adjacent)) { + throw new \LogicException('Unable to decode an adjacent float'); + } + + return $adjacent; + } } diff --git a/src/Internal/ParameterSerializer.php b/src/Internal/ParameterSerializer.php index 4327d57..6066113 100644 --- a/src/Internal/ParameterSerializer.php +++ b/src/Internal/ParameterSerializer.php @@ -64,7 +64,7 @@ private function simple(string|array $value, bool $explode, string $pairSeparato } /** - * Refuses a header value that no HTTP field can carry. + * Refuses a header name or value that no HTTP field can carry. * * Percent-encoding used to make this unreachable: a CR or an LF in a case * came out as `%0D%0A` and travelled harmlessly. A header is written as @@ -77,6 +77,9 @@ private function simple(string|array $value, bool $explode, string $pairSeparato */ public static function assertTransmittableHeader(string $name, string $value): void { + if (preg_match('/\A[!#$%&\'*+\-.^_`|~0-9A-Za-z]+\z/', $name) !== 1) { + throw new UnsupportedGeneration(sprintf('Header name "%s" is invalid', $name)); + } if ($value !== '' && preg_match('/\A[\x21-\x7e\x80-\xff](?:[\x20-\x7e\x80-\xff]*[\x21-\x7e\x80-\xff])?\z/', $value) !== 1) { throw new UnsupportedGeneration(sprintf('Header "%s" carries a value no HTTP field can', $name)); } diff --git a/src/RequestCaseArbitrary.php b/src/RequestCaseArbitrary.php index 833b331..359c041 100644 --- a/src/RequestCaseArbitrary.php +++ b/src/RequestCaseArbitrary.php @@ -9,6 +9,7 @@ use Rasuvaeff\PropertyTesting\Gen; use Rasuvaeff\PropertyTesting\OpenApi\Internal\MediaType; use Rasuvaeff\PropertyTesting\OpenApi\Internal\ParameterSchemas; +use Rasuvaeff\PropertyTesting\OpenApi\Internal\ParameterSerializer; use Rasuvaeff\PropertyTesting\OpenApi\Internal\RequestSchemas; use Rasuvaeff\PropertyTesting\OpenApi\Internal\SchemaShape; use Rasuvaeff\PropertyTesting\OpenApi\Internal\WireValue; @@ -391,6 +392,7 @@ private function multipartBody(string $mediaType, array $schema, array $definiti $contentType = is_string($configuredType) && $configuredType !== '' ? $configuredType : $this->multipartContentType($partSchema); + ParameterSerializer::assertTransmittableHeader('Content-Type', $contentType); $headers = $this->multipartHeaders($configuration['headers'] ?? []); $items = is_array($value[$name]) && array_is_list($value[$name]) ? $value[$name] : [$value[$name]]; $parts = array_merge($parts, array_map(function (mixed $partValue) use ($name, $contentType, $headers): array { @@ -483,7 +485,9 @@ private function multipartHeaders(mixed $headers): array } /** @var mixed $headerValue */ $headerValue = is_scalar($definition['example'] ?? null) ? $definition['example'] : (is_scalar($definition['default'] ?? null) ? $definition['default'] : 'x-openapi'); - $result[$name] = $this->scalar($headerValue); + $value = $this->scalar($headerValue); + ParameterSerializer::assertTransmittableHeader($name, $value); + $result[$name] = $value; } return $result; diff --git a/src/RequestMaterializer.php b/src/RequestMaterializer.php index dbdd197..37a9cc6 100644 --- a/src/RequestMaterializer.php +++ b/src/RequestMaterializer.php @@ -355,8 +355,10 @@ private function multipartBody(array $parts, string $boundary): string $payload .= '--' . $boundary . "\r\n"; $payload .= 'Content-Disposition: form-data; name="' . $this->quoteHeader($name) . '"' . ($part['encoding'] === 'base64' ? '; filename="' . $this->quoteHeader($name) . '"' : '') . "\r\n"; + ParameterSerializer::assertTransmittableHeader('Content-Type', $contentType); $payload .= 'Content-Type: ' . $contentType . "\r\n"; foreach ($headers as $header => $headerValue) { + ParameterSerializer::assertTransmittableHeader($header, $headerValue); $payload .= $header . ': ' . $headerValue . "\r\n"; } $payload .= "\r\n" . $value . "\r\n"; diff --git a/tests/RequestCaseArbitraryTest.php b/tests/RequestCaseArbitraryTest.php index 68365a8..48d06f8 100644 --- a/tests/RequestCaseArbitraryTest.php +++ b/tests/RequestCaseArbitraryTest.php @@ -41,6 +41,66 @@ #[Covers(RequestMaterializer::class)] final class RequestCaseArbitraryTest { + public function multipartEncodingRejectsHeaderInjection(): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage('Header "X-Trace" carries a value no HTTP field can'); + + $operation = new Operation( + key: 'upload.create', + operationId: 'upload.create', + method: 'POST', + path: '/upload', + requestBody: [ + 'required' => true, + 'content' => [ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['title'], + 'properties' => ['title' => ['const' => 'ok']], + ], + 'encoding' => [ + 'title' => [ + 'headers' => [ + 'X-Trace' => ['required' => true, 'default' => "ok\r\nX-Injected: yes"], + ], + ], + ], + ], + ], + ], + ); + + (new RequestCaseArbitrary())->forOperation($operation)->generate(new Random(1)); + } + + public function multipartEncodingRejectsContentTypeInjection(): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage('Header "Content-Type" carries a value no HTTP field can'); + + $operation = new Operation( + key: 'upload.create', + operationId: 'upload.create', + method: 'POST', + path: '/upload', + requestBody: [ + 'required' => true, + 'content' => [ + 'multipart/form-data' => [ + 'schema' => [ + 'type' => 'object', + 'required' => ['title'], + 'properties' => ['title' => ['const' => 'ok']], + ], + 'encoding' => ['title' => ['contentType' => "text/plain\r\nX-Injected: yes"]], + ], + ], + ], + ); + + (new RequestCaseArbitrary())->forOperation($operation)->generate(new Random(1)); + } + #[Property(runs: 100)] public function generatedCaseMaterializesToAValidRequest(array $case): void { diff --git a/tests/RequestMaterializerTest.php b/tests/RequestMaterializerTest.php index 87e3c63..fa8bb41 100644 --- a/tests/RequestMaterializerTest.php +++ b/tests/RequestMaterializerTest.php @@ -674,6 +674,49 @@ public function escapesMultipartPartNamesWithoutAllowingHeaderInjection(): void ); } + #[DataProvider('unsafeMultipartPartHeaderProvider')] + public function rejectsMultipartPartHeadersThatCannotTravel(string $contentType, array $headers, string $message): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage($message); + + $factory = new Psr17Factory(); + (new RequestMaterializer($factory, $factory))->materialize( + $this->bodyOperation([]), + $this->bodyCase('body.test', [ + 'mediaType' => 'multipart/form-data', + 'encoding' => 'multipart', + 'boundary' => 'boundary', + 'parts' => [[ + 'name' => 'field', + 'value' => 'value', + 'encoding' => 'text', + 'contentType' => $contentType, + 'headers' => $headers, + ]], + ]), + ); + } + + /** @return iterable, string}> */ + public static function unsafeMultipartPartHeaderProvider(): iterable + { + yield 'content type injection' => [ + "text/plain\r\nX-Injected: yes", + [], + 'Header "Content-Type" carries a value no HTTP field can', + ]; + yield 'header name injection' => [ + 'text/plain', + ["X-Trace\r\nX-Injected" => 'yes'], + 'Header name "X-Trace' . "\r\n" . 'X-Injected" is invalid', + ]; + yield 'header value injection' => [ + 'text/plain', + ['X-Trace' => "yes\r\nX-Injected: yes"], + 'Header "X-Trace" carries a value no HTTP field can', + ]; + } + public function rejectsMultipartWithoutParts(): void { Expect::exception(UnsupportedGeneration::class)->withMessage('Multipart request body has an invalid shape'); diff --git a/tests/SchemaArbitraryCompilerTest.php b/tests/SchemaArbitraryCompilerTest.php index fee4ca6..f4a2b70 100644 --- a/tests/SchemaArbitraryCompilerTest.php +++ b/tests/SchemaArbitraryCompilerTest.php @@ -1072,6 +1072,82 @@ public function exclusiveBoundsStepInsideNarrowWindows(): void } } + public function exclusiveBoundsStayOutsideAtTheSubnormalLimits(): void + { + $compiler = new SchemaArbitraryCompiler(); + + Assert::same( + Gen::sample($compiler->compile([ + 'type' => 'number', + 'minimum' => 0.0, + 'maximum' => 5e-324, + 'exclusiveMinimum' => true, + ]), count: 4, seed: 1), + [5e-324, 5e-324, 5e-324, 5e-324], + ); + Assert::same( + Gen::sample($compiler->compile([ + 'type' => 'number', + 'minimum' => -5e-324, + 'maximum' => 0.0, + 'exclusiveMaximum' => true, + ]), count: 4, seed: 1), + [-5e-324, -5e-324, -5e-324, -5e-324], + ); + } + + public function exclusiveBoundsUseTheAdjacentNormalDouble(): void + { + $compiler = new SchemaArbitraryCompiler(); + + Assert::same( + Gen::sample($compiler->compile([ + 'type' => 'number', + 'minimum' => 1.0, + 'maximum' => 1.0000000000000002, + 'exclusiveMinimum' => true, + ]), count: 4, seed: 1), + [1.0000000000000002, 1.0000000000000002, 1.0000000000000002, 1.0000000000000002], + ); + Assert::same( + Gen::sample($compiler->compile([ + 'type' => 'number', + 'minimum' => -1.0000000000000002, + 'maximum' => -1.0, + 'exclusiveMaximum' => true, + ]), count: 4, seed: 1), + [-1.0000000000000002, -1.0000000000000002, -1.0000000000000002, -1.0000000000000002], + ); + } + + public function exclusiveMinimumCarriesThroughTheMantissaBytes(): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation: number bounds leave no value'); + + (new SchemaArbitraryCompiler())->compile([ + 'type' => 'number', + 'minimum' => 0.9999999999999999, + 'exclusiveMinimum' => true, + 'maximum' => 1.0, + 'exclusiveMaximum' => true, + ]); + } + + public function exclusiveMaximumOnAPositiveValueBorrowsThroughTheLowBytes(): void + { + $compiler = new SchemaArbitraryCompiler(); + + Assert::same( + Gen::sample($compiler->compile([ + 'type' => 'number', + 'minimum' => 0.9999999999999999, + 'maximum' => 1.0, + 'exclusiveMaximum' => true, + ]), count: 4, seed: 1), + [0.9999999999999999, 0.9999999999999999, 0.9999999999999999, 0.9999999999999999], + ); + } + public function fractionalIntegerBoundsRoundInward(): void { $compiler = new SchemaArbitraryCompiler(); @@ -1178,6 +1254,140 @@ public function integerMultiplesRoundInwardAtTheBoundaries(): void Assert::same(array_values(array_unique($values)), [8]); } + public function integerMultiplesKeepExtremeBoundsInTheIntegerDomain(): void + { + $compiler = new SchemaArbitraryCompiler(); + $values = Gen::sample($compiler->compile([ + 'type' => 'integer', + 'minimum' => PHP_INT_MAX - 1, + 'maximum' => PHP_INT_MAX, + 'multipleOf' => 2, + ]), count: 4, seed: 1); + + Assert::same($values, [PHP_INT_MAX - 1, PHP_INT_MAX - 1, PHP_INT_MAX - 1, PHP_INT_MAX - 1]); + } + + public function integerMultiplesRoundBothSignsIntoTheirBoundedDomain(): void + { + $compiler = new SchemaArbitraryCompiler(); + $positive = Gen::sample($compiler->compile([ + 'type' => 'integer', + 'minimum' => 1, + 'maximum' => 9, + 'multipleOf' => 4, + ]), count: 100, seed: 19); + $negative = Gen::sample($compiler->compile([ + 'type' => 'integer', + 'minimum' => -9, + 'maximum' => -3, + 'multipleOf' => 4, + ]), count: 100, seed: 19); + + Assert::same(min($positive), 4); + Assert::same(max($positive), 8); + Assert::same(min($negative), -8); + Assert::same(max($negative), -4); + } + + public function integerMultiplesOnADivisibleNegativeMaximumStayExact(): void + { + $compiler = new SchemaArbitraryCompiler(); + + Assert::same( + Gen::sample($compiler->compile([ + 'type' => 'integer', + 'minimum' => -9, + 'maximum' => -9, + 'multipleOf' => 3, + ]), count: 4, seed: 1), + [-9, -9, -9, -9], + ); + } + + public function integerMultiplesOnANonDivisibleNegativeMaximumStayBelowIt(): void + { + $compiler = new SchemaArbitraryCompiler(); + $values = Gen::sample($compiler->compile([ + 'type' => 'integer', + 'minimum' => -10, + 'maximum' => -1, + 'multipleOf' => 3, + ]), count: 100, seed: 1); + + $unique = array_values(array_unique($values)); + sort($unique); + + Assert::same($unique, [-9, -6, -3]); + } + + public function integerMultiplesReachTheNativeMinimum(): void + { + $compiler = new SchemaArbitraryCompiler(); + + Assert::same( + Gen::sample($compiler->compile([ + 'type' => 'integer', + 'minimum' => PHP_INT_MIN, + 'maximum' => PHP_INT_MIN, + 'multipleOf' => 2, + ]), count: 2, seed: 1), + [PHP_INT_MIN, PHP_INT_MIN], + ); + } + + #[DataProvider('integerExclusiveNativeLimitProvider')] + public function integerExclusiveNativeLimitsFailClosed(array $schema): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation: integer bounds leave no value'); + + (new SchemaArbitraryCompiler())->compile($schema); + } + + /** @return iterable}> */ + public static function integerExclusiveNativeLimitProvider(): iterable + { + yield 'exclusive minimum at the native maximum' => [['type' => 'integer', 'minimum' => PHP_INT_MAX, 'exclusiveMinimum' => true]]; + yield 'exclusive maximum at the native minimum' => [['type' => 'integer', 'maximum' => PHP_INT_MIN, 'exclusiveMaximum' => true]]; + yield 'exclusive minimum at the native maximum with the maximum beside it' => [['type' => 'integer', 'minimum' => PHP_INT_MAX, 'maximum' => PHP_INT_MAX, 'exclusiveMinimum' => true]]; + yield 'exclusive maximum at the native minimum with the minimum beside it' => [['type' => 'integer', 'minimum' => PHP_INT_MIN, 'maximum' => PHP_INT_MIN, 'exclusiveMaximum' => true]]; + } + + public function numberMultiplesOutsideTheIndexDomainFailClosed(): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation: number multipleOf index is outside the supported integer range'); + + (new SchemaArbitraryCompiler())->compile([ + 'type' => 'number', + 'minimum' => PHP_INT_MAX - 1, + 'maximum' => PHP_INT_MAX, + 'multipleOf' => 1, + ]); + } + + public function numberBoundsRejectNonFiniteValues(): void + { + Expect::exception(UnsupportedGeneration::class)->withMessage('Unsupported OpenAPI schema generation: number bounds must be finite'); + + (new SchemaArbitraryCompiler())->compile([ + 'type' => 'number', + 'minimum' => 0.0, + 'maximum' => INF, + ]); + } + + public function numberMultiplesKeepTheNativeMinimumIndex(): void + { + Assert::same( + Gen::sample((new SchemaArbitraryCompiler())->compile([ + 'type' => 'number', + 'minimum' => PHP_INT_MIN, + 'maximum' => PHP_INT_MIN, + 'multipleOf' => 1, + ]), count: 2, seed: 1), + [(float) PHP_INT_MIN, (float) PHP_INT_MIN], + ); + } + public function numberBoundsSupportSingleValueWindows(): void { $compiler = new SchemaArbitraryCompiler();