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
13 changes: 11 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,19 @@ byte-budget throw, the scheme-detection regex anchor whose removal only
widens an already fail-closed rejection (a colon in a later path segment),
and the canonical-delimiter index in `ParameterCodec::parseDelimitedQuery()`
(every wire form of a delimiter is folded to the chosen one before the
split, so any element of the list produces the same partition), and the
split, so any element of the list produces the same partition), the
`array_values()` calls over a Path Item's and an Operation's `parameters`
in `DocumentCompiler::parameters()` (a JSON array decodes to a list, so
the re-index has nothing to change and only the pointer index would move).
the re-index has nothing to change and only the pointer index would move),
and — since the message-reading trait joined the coverage map — the whole
chunking arithmetic of `MessageReading::bodyContents()`: a larger `$remaining`
or a wider `read()` window still lands on the same `> $maxBytes` check, and
`break` against `continue` on an at-eof empty chunk differ only in re-testing
the `while` condition that is already false. The media-type selection helpers
in the same trait escape for the reasons above: the rank sentinel is below
every specificity, the key/definition type guard is reachable only through a
hand-built `Operation`, and the strict `>` is untestable because no two
declarations of equal specificity can match one media type.

## When you finish

Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,26 @@ 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).

## Unreleased

- **Fixed.** A YAML document under a kilobyte could exhaust memory and die with
a fatal error rather than an `InvalidContract`. Anchors and aliases expand
inside the parser, before any budget measures anything, and the byte budget
measures the file; the reference resolver's own budget counts only the nodes
it descends into, and it rightly does not descend into data — which is where
an alias is just as welcome. Nine levels of anchors in an `enum` turned 772
bytes into 387 million nodes. Documents are now measured by what they expand
into, at every entry point and shared across a multi-file graph.
- **Added.** `Limits::$documentNodes` (default 5 000 000, constant
`Limits::DEFAULT_DOCUMENT_NODES`) — the budget above. It sits above what any
document within `documentBytes` can hold, so it refuses amplification without
refusing size. Counting stops at the budget, so an oversized document costs
the budget rather than its own size.
- **Changed.** The message-reading trait joined the mutation gate's coverage
map: it was in no `#[Covers]`, and so produced no mutants at all — the body
reading loop, its byte-budget comparison included, was outside the gate. The
package's own `AGENTS.md` names that as the symptom.

## 0.9.0 — 2026-09-06

The three decisions a 1.0 tag would have frozen, settled while they are still
Expand Down
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,12 @@ absolute paths, URI schemes, percent-encoded paths, traversal, and symlink
escapes are rejected before any read, and resolution errors report paths
relative to the document root. `fromArray()` and `fromJson()` have no
trusted filesystem root and accept same-document references only.
Documents are bounded: byte size, JSON depth, `$ref` depth, a shared node
budget, and — for multi-file documents — file-count and byte budgets shared
across the whole reference graph.
Documents are bounded: byte size, JSON depth, `$ref` depth, the number of
nodes a document expands into, a reference-resolution budget, and — for
multi-file documents — file-count, byte and node budgets shared across the
whole reference graph. The node budget is the one that bounds YAML: anchors
and aliases produce nodes out of no bytes at all, so a file well inside the
byte budget can still expand into hundreds of millions of nodes.

#### Budgets

Expand All @@ -110,6 +113,7 @@ $contract = Contract::fromFile('openapi.yaml', new Limits(
documentBytes: 40 * 1024 * 1024, // default 10 MiB
messageBodyBytes: 8 * 1024 * 1024, // default 1 MiB
documentFiles: 256, // default 64
documentNodes: 20_000_000, // default 5 000 000
));
```

Expand Down
8 changes: 6 additions & 2 deletions README.ru.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,11 @@ traversal и symlink escape отклоняются до какого-либо ч
резолюции показывают пути относительно document root. У `fromArray()` и
`fromJson()` нет доверенного filesystem root — они принимают только
same-document ссылки. Документы ограничены бюджетами: размер в байтах, JSON
depth, глубина `$ref`, общий node budget, а для многофайловых документов —
общие на весь граф бюджеты числа файлов и байтов.
depth, глубина `$ref`, число узлов, в которое документ разворачивается, бюджет
резолюции ссылок, а для многофайловых документов — общие на весь граф бюджеты
числа файлов, байтов и узлов. Именно узловой бюджет ограничивает YAML: якоря и
алиасы делают узлы из ничего, поэтому файл, спокойно проходящий по байтам,
может развернуться в сотни миллионов узлов.

#### Бюджеты

Expand All @@ -110,6 +113,7 @@ $contract = Contract::fromFile('openapi.yaml', new Limits(
documentBytes: 40 * 1024 * 1024, // по умолчанию 10 MiB
messageBodyBytes: 8 * 1024 * 1024, // по умолчанию 1 MiB
documentFiles: 256, // по умолчанию 64
documentNodes: 20_000_000, // по умолчанию 5 000 000
));
```

Expand Down
13 changes: 8 additions & 5 deletions llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,10 @@ Rules:
an unencoded one) — it is a hand-off to consumers that render query values,
which cannot derive it from the schema;
- budgets are `Limits`, not constants: `new Limits(documentBytes:,
messageBodyBytes:, documentFiles:)` passed to any factory; a budget below 1
throws `\InvalidArgumentException`. `*.body.too_large` means the validator
messageBodyBytes:, documentFiles:, documentNodes:)` passed to any factory; a
budget below 1 throws `\InvalidArgumentException`. `documentNodes` bounds
what a document expands into, which for YAML anchors is unrelated to what it
weighs. `*.body.too_large` means the validator
declined to read that body, NOT that the message is invalid — raise the
budget rather than treating it as a failed verdict;
- `Operation` is a read model: its constructor is `@internal` (nothing public
Expand Down Expand Up @@ -112,9 +114,10 @@ API reference:
symfony/yaml; multi-file relative `$ref`s under the entry directory) build
the immutable compiled contract.
- `Limits` (readonly): `documentBytes` (default 10 MiB),
`messageBodyBytes` (default 1 MiB), `documentFiles` (default 64), with
`Limits::DEFAULT_DOCUMENT_BYTES` / `DEFAULT_MESSAGE_BODY_BYTES` /
`DEFAULT_DOCUMENT_FILES` as the constants behind them.
`messageBodyBytes` (default 1 MiB), `documentFiles` (default 64),
`documentNodes` (default 5 000 000), with `Limits::DEFAULT_DOCUMENT_BYTES` /
`DEFAULT_MESSAGE_BODY_BYTES` / `DEFAULT_DOCUMENT_FILES` /
`DEFAULT_DOCUMENT_NODES` as the constants behind them.
- `Contract::operations(): list<Operation>`;
`Contract::operation(string $key): Operation` throws `UnknownOperation`.
- `Contract::securitySchemes(): array<string, CompiledSecurityScheme>` —
Expand Down
9 changes: 7 additions & 2 deletions src/Contract.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
use Psr\Http\Message\RequestInterface;
use Psr\Http\Message\ResponseInterface;
use Rasuvaeff\OpenApiContract\Internal\Compilation\DocumentCompiler;
use Rasuvaeff\OpenApiContract\Internal\Compilation\DocumentNodes;
use Rasuvaeff\OpenApiContract\Internal\Reference\DocumentGraph;
use Rasuvaeff\OpenApiContract\Internal\Schema\SchemaDialect;
use Rasuvaeff\OpenApiContract\Internal\Schema\SchemaValidator;
Expand Down Expand Up @@ -67,9 +68,13 @@
/** @param array<string, mixed> $document */
public static function fromArray(array $document, ?Limits $limits = null): self
{
$limits ??= new Limits();
if (DocumentNodes::within($document, $limits->documentNodes) === null) {
throw new InvalidContract(sprintf('OpenAPI document expands to more than %d nodes', $limits->documentNodes));
}
$compiled = (new DocumentCompiler())->compile($document);

return new self($compiled->dialect, $compiled->operations, $compiled->securitySchemes, $limits ?? new Limits());
return new self($compiled->dialect, $compiled->operations, $compiled->securitySchemes, $limits);
}

public static function fromJson(string $json, string $source = 'openapi.json', ?Limits $limits = null): self
Expand Down Expand Up @@ -100,7 +105,7 @@
public static function fromFile(string $path, ?Limits $limits = null): self
{
$limits ??= new Limits();
$graph = DocumentGraph::open($path, $limits->documentFiles, $limits->documentBytes);
$graph = DocumentGraph::open($path, $limits->documentFiles, $limits->documentBytes, $limits->documentNodes);
$compiled = (new DocumentCompiler())->compile($graph->entryDocument(), $graph);

return new self($compiled->dialect, $compiled->operations, $compiled->securitySchemes, $limits);
Expand Down Expand Up @@ -186,7 +191,7 @@
$bRoute = $b[4] ?? null;
$aOperation = $a[0] ?? null;
$bOperation = $b[0] ?? null;
if (!is_string($aRoute) || !is_string($bRoute) || !$aOperation instanceof Operation || !$bOperation instanceof Operation) {

Check warning on line 194 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOr": @@ @@ $bRoute = $b[4] ?? null; $aOperation = $a[0] ?? null; $bOperation = $b[0] ?? null; - if (!is_string($aRoute) || !is_string($bRoute) || !$aOperation instanceof Operation || !$bOperation instanceof Operation) { + if (!is_string($aRoute) || !is_string($bRoute) || !$aOperation instanceof Operation && !$bOperation instanceof Operation) { throw new \LogicException('Operation match candidate has an invalid shape'); }

Check warning on line 194 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOr": @@ @@ $bRoute = $b[4] ?? null; $aOperation = $a[0] ?? null; $bOperation = $b[0] ?? null; - if (!is_string($aRoute) || !is_string($bRoute) || !$aOperation instanceof Operation || !$bOperation instanceof Operation) { + if (!is_string($aRoute) || !is_string($bRoute) && !$aOperation instanceof Operation || !$bOperation instanceof Operation) { throw new \LogicException('Operation match candidate has an invalid shape'); }

Check warning on line 194 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "LogicalOr": @@ @@ $bRoute = $b[4] ?? null; $aOperation = $a[0] ?? null; $bOperation = $b[0] ?? null; - if (!is_string($aRoute) || !is_string($bRoute) || !$aOperation instanceof Operation || !$bOperation instanceof Operation) { + if (!is_string($aRoute) && !is_string($bRoute) || !$aOperation instanceof Operation || !$bOperation instanceof Operation) { throw new \LogicException('Operation match candidate has an invalid shape'); }
throw new \LogicException('Operation match candidate has an invalid shape');
}

Expand Down Expand Up @@ -346,7 +351,7 @@
if (str_contains($requestPart, '/') || str_contains($requestPart, '\\')) {
return null;
}
if (preg_match('/^\{([^{}]+)\}$/', $part, $match) === 1) {

Check warning on line 354 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "PregMatchMatches": @@ @@ if (str_contains($requestPart, '/') || str_contains($requestPart, '\\')) { return null; } - if (preg_match('/^\{([^{}]+)\}$/', $part, $match) === 1) { + if ((int) ($match = []) === 1) { $params[$match[1]] = $rawRequestPart; continue; }
$params[$match[1]] = $rawRequestPart;
continue;
}
Expand All @@ -355,8 +360,8 @@
if ($captured === null) {
return null;
}
$params = [...$params, ...$captured];

Check warning on line 363 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "ArrayItemRemoval": @@ @@ if ($captured === null) { return null; } - $params = [...$params, ...$captured]; + $params = [...$captured]; continue; } if ($part !== $requestPart) {
continue;

Check warning on line 364 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "Continue_": @@ @@ return null; } $params = [...$params, ...$captured]; - continue; + break; } if ($part !== $requestPart) { return null;
}
if ($part !== $requestPart) {
return null;
Expand Down Expand Up @@ -402,11 +407,11 @@
$offset = 0;
while (preg_match('/\{([^{}]+)\}/', $template, $match, PREG_OFFSET_CAPTURE, $offset) === 1) {
[$placeholder, $position] = $match[0];
$pattern .= preg_quote(substr($template, $offset, $position - $offset), '~') . '([^/]+)';

Check warning on line 410 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "PregQuote": @@ @@ $offset = 0; while (preg_match('/\{([^{}]+)\}/', $template, $match, PREG_OFFSET_CAPTURE, $offset) === 1) { [$placeholder, $position] = $match[0]; - $pattern .= preg_quote(substr($template, $offset, $position - $offset), '~') . '([^/]+)'; + $pattern .= substr($template, $offset, $position - $offset) . '([^/]+)'; $names[] = $match[1][0]; $offset = $position + strlen($placeholder); }
$names[] = $match[1][0];
$offset = $position + strlen($placeholder);
}
$pattern .= preg_quote(substr($template, $offset), '~');

Check warning on line 414 in src/Contract.php

View workflow job for this annotation

GitHub Actions / Coverage & Mutation

Escaped Mutant for Mutator "PregQuote": @@ @@ $names[] = $match[1][0]; $offset = $position + strlen($placeholder); } - $pattern .= preg_quote(substr($template, $offset), '~'); + $pattern .= substr($template, $offset); if (preg_match('~^' . $pattern . '\z~', $rawRequestPart, $captured) !== 1) { return null; }
if (preg_match('~^' . $pattern . '\z~', $rawRequestPart, $captured) !== 1) {
return null;
}
Expand Down
52 changes: 52 additions & 0 deletions src/Internal/Compilation/DocumentNodes.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
<?php

declare(strict_types=1);

namespace Rasuvaeff\OpenApiContract\Internal\Compilation;

/**
* Counts what a parsed document actually expands into.
*
* The byte budget measures a file; YAML anchors make nodes out of no bytes at
* all, and the reference resolver's own budget counts only the nodes it
* descends into — deliberately not the data-bearing keywords (`enum`,
* `example`, `default`, `const`) where an alias is just as welcome. So a
* document under every declared budget could still expand into hundreds of
* millions of nodes and take the process down with it.
*
* Counting is iterative rather than recursive: the structure being measured is
* precisely the one that must not be allowed to exhaust anything, stack
* included.
*
* @internal
*/
final class DocumentNodes
{
/**
* @param array<array-key, mixed> $document
*
* @return int|null the node count, or `null` when the document exceeds
* the budget — counting stops there, so an oversized document
* costs the budget and not its own size
*/
public static function within(array $document, int $budget): ?int
{
$count = 0;
$stack = [$document];
while ($stack !== []) {
/** @var array<array-key, mixed> $node */
$node = array_pop($stack);
/** @var mixed $value */
foreach ($node as $value) {
if (++$count > $budget) {
return null;
}
if (is_array($value)) {
$stack[] = $value;
}
}
}

return $count;
}
}
20 changes: 16 additions & 4 deletions src/Internal/Reference/DocumentGraph.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

namespace Rasuvaeff\OpenApiContract\Internal\Reference;

use Rasuvaeff\OpenApiContract\Internal\Compilation\DocumentNodes;
use Rasuvaeff\OpenApiContract\InvalidContract;
use Rasuvaeff\OpenApiContract\Limits;

Expand All @@ -22,22 +23,25 @@ final class DocumentGraph
/** @var array<string, array<string, mixed>> */
private array $documents = [];

private function __construct(private readonly string $root, private readonly string $entryPath, private readonly int $maximumFiles, private int $remainingBytes) {}
private function __construct(private readonly string $root, private readonly string $entryPath, private readonly int $maximumFiles, private int $remainingBytes, private int $remainingNodes) {}

public static function open(string $path, int $maximumFiles = Limits::DEFAULT_DOCUMENT_FILES, int $maximumBytes = Limits::DEFAULT_DOCUMENT_BYTES): self
public static function open(string $path, int $maximumFiles = Limits::DEFAULT_DOCUMENT_FILES, int $maximumBytes = Limits::DEFAULT_DOCUMENT_BYTES, int $maximumNodes = Limits::DEFAULT_DOCUMENT_NODES): self
{
if ($maximumFiles < 1) {
throw new \InvalidArgumentException('Maximum file count must be positive');
}
if ($maximumBytes < 1) {
throw new \InvalidArgumentException('Maximum byte budget must be positive');
}
if ($maximumNodes < 1) {
throw new \InvalidArgumentException('Maximum node budget must be positive');
}
$canonical = realpath($path);
if ($canonical === false || !is_file($canonical)) {
throw new InvalidContract(sprintf('OpenAPI document "%s" is not readable', $path));
}

$graph = new self(\dirname($canonical), $canonical, $maximumFiles, $maximumBytes);
$graph = new self(\dirname($canonical), $canonical, $maximumFiles, $maximumBytes, $maximumNodes);
$graph->document($canonical);

return $graph;
Expand Down Expand Up @@ -76,8 +80,16 @@ public function document(string $canonicalPath): array
throw new InvalidContract(sprintf('OpenAPI document "%s" exceeds the shared byte budget', $display));
}
$this->remainingBytes -= strlen($contents);
$parsed = $this->parse($canonicalPath, $display, $contents);
// Bytes are what the file weighs; nodes are what it expands into, and
// YAML anchors make the two unrelated.
$nodes = DocumentNodes::within($parsed, $this->remainingNodes);
if ($nodes === null) {
throw new InvalidContract(sprintf('OpenAPI document "%s" exceeds the shared node budget', $display));
}
$this->remainingNodes -= $nodes;

return $this->documents[$canonicalPath] = $this->parse($canonicalPath, $display, $contents);
return $this->documents[$canonicalPath] = $parsed;
}

/**
Expand Down
11 changes: 11 additions & 0 deletions src/Limits.php
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,26 @@
* middleware is a denial of service; a caller whose traffic is legitimately
* larger raises the budget here instead of losing the verdict.
*
* `documentNodes` bounds what a document expands into rather than what it
* weighs: YAML anchors produce nodes out of no bytes, so the byte budget alone
* does not bound the memory a document costs. The default sits above what any
* document within `documentBytes` can hold, so it refuses amplification
* without refusing size.
*
* @api
*/
final readonly class Limits
{
public const int DEFAULT_DOCUMENT_BYTES = 10 * 1024 * 1024;
public const int DEFAULT_MESSAGE_BODY_BYTES = 1024 * 1024;
public const int DEFAULT_DOCUMENT_FILES = 64;
public const int DEFAULT_DOCUMENT_NODES = 5_000_000;

public function __construct(
public int $documentBytes = self::DEFAULT_DOCUMENT_BYTES,
public int $messageBodyBytes = self::DEFAULT_MESSAGE_BODY_BYTES,
public int $documentFiles = self::DEFAULT_DOCUMENT_FILES,
public int $documentNodes = self::DEFAULT_DOCUMENT_NODES,
) {
if ($documentBytes < 1) {
throw new \InvalidArgumentException('Document byte budget must be positive');
Expand All @@ -36,5 +44,8 @@ public function __construct(
if ($documentFiles < 1) {
throw new \InvalidArgumentException('Document file budget must be positive');
}
if ($documentNodes < 1) {
throw new \InvalidArgumentException('Document node budget must be positive');
}
}
}
34 changes: 34 additions & 0 deletions tests/ContractTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use Rasuvaeff\OpenApiContract\ContractViolation;
use Rasuvaeff\OpenApiContract\Internal\Compilation\CompiledDocument;
use Rasuvaeff\OpenApiContract\Internal\Compilation\DocumentCompiler;
use Rasuvaeff\OpenApiContract\Internal\Compilation\DocumentNodes;
use Rasuvaeff\OpenApiContract\Internal\Exception\UnsupportedDialect;
use Rasuvaeff\OpenApiContract\InvalidContract;
use Rasuvaeff\OpenApiContract\Limits;
Expand All @@ -31,6 +32,7 @@
#[Covers(Contract::class)]
#[Covers(CompiledDocument::class)]
#[Covers(DocumentCompiler::class)]
#[Covers(DocumentNodes::class)]
#[Covers(InvalidContract::class)]
#[Covers(UnknownOperation::class)]
#[Covers(UnsupportedSerialization::class)]
Expand Down Expand Up @@ -471,6 +473,38 @@ public function reportsTheAmbiguousPathsWithTheUppercaseMethod(): void
}
}

/**
* The byte budget measures the document; this one measures what it
* expands into, which for YAML aliases is unrelated.
*/
public function refusesADocumentOverTheConfiguredNodeBudget(): void
{
$document = ['openapi' => '3.1.0', 'paths' => ['/h' => ['get' => [
'parameters' => [['name' => 'q', 'in' => 'query', 'schema' => ['type' => 'string', 'enum' => range(1, 40)]]],
'responses' => ['200' => []],
]]]];

try {
Contract::fromArray($document, new Limits(documentNodes: 20));
Assert::true(actual: false, message: 'Expected the node budget to refuse the document');
} catch (InvalidContract $exception) {
Assert::same($exception->getMessage(), 'OpenAPI document expands to more than 20 nodes');
}

Assert::same(Contract::fromArray($document, new Limits(documentNodes: 200))->operations()[0]->path, '/h');
}

public function countsEveryNodeOfADocumentOnce(): void
{
Assert::same(DocumentNodes::within(['a' => 1, 'b' => ['c' => 2]], 100), 3);
Assert::same(DocumentNodes::within([], 100), 0);
Assert::same(DocumentNodes::within(['a' => 1, 'b' => 2], 2), 2);
Assert::null(DocumentNodes::within(['a' => 1, 'b' => 2], 1));
// The count stops at the budget, so an oversized document costs the
// budget rather than its own size.
Assert::null(DocumentNodes::within(['a' => range(1, 10_000)], 3));
}

public function refusesADocumentOverTheConfiguredByteBudget(): void
{
$json = '{"openapi":"3.1.0","paths":{"/h":{"get":{"responses":{"200":{}}}}}}';
Expand Down
Loading
Loading