From a59a14c6ffd110bfab85254ac37af7d113c5fd2e Mon Sep 17 00:00:00 2001 From: "kresimir.lezaic" Date: Thu, 23 Apr 2026 11:14:40 +0200 Subject: [PATCH 1/2] fix: validate stream name in createStream/updateStream --- src/JetStream/JetStreamContext.php | 23 ++++++ ...tStreamContextStreamNameValidationTest.php | 80 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php diff --git a/src/JetStream/JetStreamContext.php b/src/JetStream/JetStreamContext.php index 7c083d4..0d1222c 100644 --- a/src/JetStream/JetStreamContext.php +++ b/src/JetStream/JetStreamContext.php @@ -42,20 +42,24 @@ public function connection(): Connection // --- Stream Management --- /** + * @throws \InvalidArgumentException If the stream name is invalid * @throws JetStreamException If the stream cannot be created */ public function createStream(StreamConfig $config): StreamInterface { + $this->validateStreamName($config->name); $data = $this->apiRequest('STREAM.CREATE.' . $config->name, $config->toArray()); $info = StreamInfo::fromArray($data); return new Stream($this, $config->name, $info); } /** + * @throws \InvalidArgumentException If the stream name is invalid * @throws JetStreamException If the stream cannot be updated */ public function updateStream(StreamConfig $config): StreamInterface { + $this->validateStreamName($config->name); $data = $this->apiRequest('STREAM.UPDATE.' . $config->name, $config->toArray()); $info = StreamInfo::fromArray($data); return new Stream($this, $config->name, $info); @@ -561,6 +565,25 @@ public function apiRequest(string $operation, ?array $payload = null): array // --- Private helpers --- + /** + * Validates a JetStream stream name per NATS server rules. Stream names become tokens + * in `$JS.API.STREAM.*.` subjects, so they cannot contain anything that would + * split a subject token or fail server-side name validation. Sending an invalid name + * otherwise surfaces as a request timeout, which is hard to diagnose. + */ + private function validateStreamName(string $name): void + { + if ($name === '') { + throw new \InvalidArgumentException('Stream name cannot be empty'); + } + + if (preg_match('/[\s.*>\/\\\\\x00-\x1f\x7f]/', $name)) { + throw new \InvalidArgumentException( + "Invalid stream name '{$name}': cannot contain whitespace, '.', '*', '>', '/', '\\', or control characters", + ); + } + } + private function buildKvStreamConfig(\Sofascore\Nats\KeyValue\KeyValueConfig $config): StreamConfig { $streamName = "KV_{$config->bucket}"; diff --git a/tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php b/tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php new file mode 100644 index 0000000..470b044 --- /dev/null +++ b/tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php @@ -0,0 +1,80 @@ +newInstanceWithoutConstructor(); + $method->invoke($context, $name); + } + + #[DataProvider('validNames')] + public function testAcceptsValidStreamName(string $name): void + { + self::invokeValidate($name); + $this->addToAssertionCount(1); + } + + /** + * @return iterable + */ + public static function validNames(): iterable + { + yield 'simple' => ['orders']; + yield 'with-dash' => ['my-stream']; + yield 'with-underscore' => ['KV_cache']; + yield 'alphanumeric' => ['events123']; + yield 'mixed-case' => ['MyStream']; + yield 'object-store-prefix' => ['OBJ_media']; + } + + #[DataProvider('invalidNames')] + public function testRejectsInvalidStreamName(string $name): void + { + $this->expectException(\InvalidArgumentException::class); + self::invokeValidate($name); + } + + /** + * @return iterable + */ + public static function invalidNames(): iterable + { + yield 'empty' => ['']; + yield 'with-space' => ['my stream']; + yield 'with-tab' => ["my\tstream"]; + yield 'with-cr' => ["my\rstream"]; + yield 'with-lf' => ["my\nstream"]; + yield 'with-dot' => ['my.stream']; + yield 'with-star' => ['my*stream']; + yield 'with-gt' => ['my>stream']; + yield 'with-slash' => ['my/stream']; + yield 'with-backslash' => ['my\\stream']; + yield 'with-null-byte' => ["my\x00stream"]; + yield 'with-del' => ["my\x7fstream"]; + yield 'only-space' => [' ']; + } + + public function testInvalidNameMessageMentionsTheName(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('bad name'); + self::invokeValidate('bad name'); + } + + public function testEmptyNameMessageSaysEmpty(): void + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('empty'); + self::invokeValidate(''); + } +} From e819fd72968076d4fae03ce06a0233515abd7c07 Mon Sep 17 00:00:00 2001 From: "kresimir.lezaic" Date: Thu, 23 Apr 2026 11:14:40 +0200 Subject: [PATCH 2/2] fix: validate stream name in createStream/updateStream --- ...tStreamContextStreamNameValidationTest.php | 29 ++++++++++++++----- 1 file changed, 21 insertions(+), 8 deletions(-) diff --git a/tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php b/tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php index 470b044..f8ae644 100644 --- a/tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php +++ b/tests/Unit/JetStream/JetStreamContextStreamNameValidationTest.php @@ -64,17 +64,30 @@ public static function invalidNames(): iterable yield 'only-space' => [' ']; } - public function testInvalidNameMessageMentionsTheName(): void + public function testInvalidNameProducesDescriptiveMessage(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('bad name'); - self::invokeValidate('bad name'); + try { + self::invokeValidate('bad.name'); + self::fail('Expected InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + self::assertSame( + "Invalid stream name 'bad.name': cannot contain whitespace, '.', '*', '>', '/', '\\', or control characters", + $e->getMessage(), + ); + self::assertSame(0, $e->getCode()); + self::assertNull($e->getPrevious()); + } } - public function testEmptyNameMessageSaysEmpty(): void + public function testEmptyNameProducesDescriptiveMessage(): void { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('empty'); - self::invokeValidate(''); + try { + self::invokeValidate(''); + self::fail('Expected InvalidArgumentException'); + } catch (\InvalidArgumentException $e) { + self::assertSame('Stream name cannot be empty', $e->getMessage()); + self::assertSame(0, $e->getCode()); + self::assertNull($e->getPrevious()); + } } }