Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
116 changes: 102 additions & 14 deletions src/Internal/Compile/ScalarArbitraries.php
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -204,34 +212,33 @@ 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<string, mixed> $schema */
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');
Expand All @@ -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');
}
Expand All @@ -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;
}
}
5 changes: 4 additions & 1 deletion src/Internal/ParameterSerializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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));
}
Expand Down
6 changes: 5 additions & 1 deletion src/RequestCaseArbitrary.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions src/RequestMaterializer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
60 changes: 60 additions & 0 deletions tests/RequestCaseArbitraryTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
43 changes: 43 additions & 0 deletions tests/RequestMaterializerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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, array{string, array<string, string>, 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');
Expand Down
Loading
Loading