diff --git a/.changeset/seven-suns-strive.md b/.changeset/seven-suns-strive.md new file mode 100644 index 00000000..93f3322e --- /dev/null +++ b/.changeset/seven-suns-strive.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Normalize NaN and infinity to null in bounded data-only JSON payloads, while preserving native encoding for objects, references and out-of-budget payloads. diff --git a/lib/Consumer/File.php b/lib/Consumer/File.php index dc4f6e36..0454b4fa 100644 --- a/lib/Consumer/File.php +++ b/lib/Consumer/File.php @@ -4,6 +4,7 @@ use Exception; use PostHog\Consumer; +use PostHog\Internal\JsonEncoder; /** * Consumer that writes analytics messages to a local file. @@ -101,7 +102,7 @@ private function write($body) return false; } - $content = json_encode($body); + $content = JsonEncoder::encode($body); $content .= "\n"; return fwrite($this->file_handle, $content) == strlen($content); diff --git a/lib/Internal/JsonEncoder.php b/lib/Internal/JsonEncoder.php new file mode 100644 index 00000000..a3f279f3 --- /dev/null +++ b/lib/Internal/JsonEncoder.php @@ -0,0 +1,98 @@ +eligible($value, 0) && $inspection->hasNonFinite) { + $changed = false; + $value = self::normalize($value, $changed); + } + return json_encode($value); + } + + private function eligible(mixed $value, int $depth): bool + { + if (--$this->remainingValues < 0) { + return false; + } + if (is_array($value)) { + if ($depth >= self::MAX_DEPTH) { + return false; + } + foreach ($value as $key => $item) { + if ( + ReflectionReference::fromArrayElement($value, $key) !== null + || (is_string($key) && !$this->eligibleString($key)) + || !$this->eligible($item, $depth + 1) + ) { + return false; + } + } + return true; + } + if (is_string($value)) { + return $this->eligibleString($value); + } + if (is_float($value) && !is_finite($value)) { + $this->hasNonFinite = true; + } + return $value === null || is_scalar($value); + } + + private function eligibleString(string $value): bool + { + $this->remainingBytes -= strlen($value); + if ($this->remainingBytes < 0) { + return false; + } + // Validation must not reset the JSON error state visible to subsequent native callbacks. + try { + json_encode($value, JSON_THROW_ON_ERROR); + return true; + } catch (JsonException) { + return false; + } + } + + private static function normalize(mixed $value, bool &$changed): mixed + { + $changed = is_float($value) && !is_finite($value); + if ($changed) { + return null; + } + if (!is_array($value)) { + return $value; + } + // Compact retained storage first; array_map also preserves numeric-string keys without PHP key coercion. + $compact = array_slice($value, 0, null, true); + $result = array_map(static function (mixed $item) use (&$changed): mixed { + $childChanged = false; + $normalized = self::normalize($item, $childChanged); + $changed = $changed || $childChanged; + return $normalized; + }, $compact); + return $changed ? $result : $value; + } +} diff --git a/lib/QueueConsumer.php b/lib/QueueConsumer.php index 30434a6e..c28dccef 100644 --- a/lib/QueueConsumer.php +++ b/lib/QueueConsumer.php @@ -2,6 +2,7 @@ namespace PostHog; +use PostHog\Internal\JsonEncoder; use Symfony\Component\Clock\Clock; use Throwable; @@ -213,7 +214,7 @@ private function now(): float */ protected function encodeBatchPayload($batch) { - $payload = json_encode($this->payload($batch)); + $payload = JsonEncoder::encode($this->payload($batch)); if (false === $payload) { $this->handleError(json_last_error(), "Failed to encode batch payload: " . json_last_error_msg()); return false; diff --git a/scripts/PublicApiSnapshot.php b/scripts/PublicApiSnapshot.php index b63c7ec9..74a7ff8d 100644 --- a/scripts/PublicApiSnapshot.php +++ b/scripts/PublicApiSnapshot.php @@ -74,7 +74,10 @@ private static function buildPublicApi(string $sourceDir, string $namespacePrefi $symbols = []; foreach (array_merge(get_declared_interfaces(), get_declared_traits(), get_declared_classes()) as $name) { - if (!str_starts_with($name, $namespacePrefix)) { + if ( + !str_starts_with($name, $namespacePrefix) + || str_starts_with($name, $namespacePrefix . 'Internal\\') + ) { continue; } diff --git a/test/JsonEncoderDataOnlyTest.php b/test/JsonEncoderDataOnlyTest.php new file mode 100644 index 00000000..022f8e96 --- /dev/null +++ b/test/JsonEncoderDataOnlyTest.php @@ -0,0 +1,272 @@ + [NAN], 'positive infinity' => [INF], 'negative infinity' => [-INF]]; + } + + #[DataProvider('nonFiniteNumbers')] + public function testDataOnlyNumbersBecomeNullWithoutChangingCallerValues(float $number): void + { + self::assertSame('null', JsonEncoder::encode($number)); + $input = ['number' => $number, 'nested' => ['items' => [false, $number, 0]], 'literal' => 'NaN']; + self::assertSame( + '{"number":null,"nested":{"items":[false,null,0]},"literal":"NaN"}', + JsonEncoder::encode($input) + ); + if (is_nan($number)) { + self::assertNan($input['number']); + self::assertNan($input['nested']['items'][1]); + } else { + self::assertSame($number, $input['number']); + self::assertSame($number, $input['nested']['items'][1]); + } + } + + public function testOpaqueObjectDisablesRepairForTheWholePayload(): void + { + $serializer = new class implements \JsonSerializable { + public int $calls = 0; + + public function jsonSerialize(): mixed + { + ++$this->calls; + return 'kept'; + } + }; + $input = ['number' => NAN, 'opaque' => $serializer]; + self::assertFalse(json_encode($input)); + $error = json_last_error(); + $nativeCalls = $serializer->calls; + $serializer->calls = 0; + self::assertFalse(JsonEncoder::encode($input)); + self::assertSame($error, json_last_error()); + self::assertSame($nativeCalls, $serializer->calls); + self::assertNan($input['number']); + } + + public function testReferenceSlotDisablesRepairForTheWholePayload(): void + { + $finite = 7; + $input = ['number' => NAN, 'reference' => &$finite]; + $identity = \ReflectionReference::fromArrayElement($input, 'reference')->getId(); + self::assertFalse(json_encode($input)); + $error = json_last_error(); + self::assertFalse(JsonEncoder::encode($input)); + self::assertSame($error, json_last_error()); + self::assertSame($identity, \ReflectionReference::fromArrayElement($input, 'reference')->getId()); + self::assertSame(7, $finite); + self::assertNan($input['number']); + } + + public function testNativeSerializerFailureIsNotRetriedOrNormalized(): void + { + $serializer = new class implements \JsonSerializable { + public int $calls = 0; + + public function jsonSerialize(): mixed + { + ++$this->calls; + return ['number' => NAN]; + } + }; + self::assertFalse(JsonEncoder::encode($serializer)); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + self::assertSame(1, $serializer->calls); + } + + public static function valueBudgets(): array + { + return ['within budget' => [9999, true], 'over budget' => [10000, false]]; + } + + #[DataProvider('valueBudgets')] + public function testValueBudgetBoundary(int $count, bool $eligible): void + { + $input = array_fill(0, $count, 0); + $input[0] = NAN; + if ($eligible) { + $expected = $input; + $expected[0] = null; + self::assertSame(json_encode($expected), JsonEncoder::encode($input)); + } else { + self::assertFalse(JsonEncoder::encode($input)); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + } + self::assertNan($input[0]); + } + + public static function byteBudgets(): array + { + return ['within budget' => [1048574, true], 'over budget' => [1048575, false]]; + } + + #[DataProvider('byteBudgets')] + public function testStringAndKeyByteBudgetBoundary(int $bytes, bool $eligible): void + { + $input = ['n' => NAN, 's' => str_repeat('A', $bytes)]; + if ($eligible) { + $expected = ['n' => null, 's' => $input['s']]; + self::assertTrue( + JsonEncoder::encode($input) === json_encode($expected), + 'A payload at the byte boundary must be repaired exactly.' + ); + } else { + self::assertTrue( + JsonEncoder::encode($input) === false, + 'An over-budget payload must retain native failure.' + ); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + } + self::assertNan($input['n']); + } + + public static function outsideBudgetShapes(): array + { + return ['values' => ['values'], 'bytes' => ['bytes'], 'depth' => ['depth']]; + } + + #[DataProvider('outsideBudgetShapes')] + public function testFinitePayloadsOutsideRepairBudgetsStillUseNativeEncoding(string $shape): void + { + $input = match ($shape) { + 'values' => array_fill(0, 10000, 0), + 'bytes' => str_repeat('A', 1048577), + 'depth' => 0, + }; + if ($shape === 'depth') { + for ($i = 0; $i < 65; ++$i) { + $input = [$input]; + } + } + $expected = json_encode($input); + self::assertIsString($expected); + self::assertTrue(JsonEncoder::encode($input) === $expected, 'Finite fallback must preserve native output.'); + self::assertSame(JSON_ERROR_NONE, json_last_error()); + } + + public static function eligibleMemoryShapes(): array + { + return ['dense non-finite arrays' => ['dense'], 'escaped string' => ['escaped']]; + } + + #[DataProvider('eligibleMemoryShapes')] + public function testEligibleRepairMatchesNativeNullDataWithin32Mb(string $shape): void + { + $outputs = []; + foreach (['native', 'sdk'] as $mode) { + $process = proc_open( + [PHP_BINARY, '-d', 'memory_limit=32M', '-d', 'max_execution_time=10', + __DIR__ . '/fixtures/json-encoder-data-only.php', + __DIR__ . '/../vendor/autoload.php', $shape, $mode], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + self::assertIsResource($process); + fclose($pipes[0]); + $outputs[$mode] = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $mode . ': ' . $errors); + } + self::assertSame($outputs['native'], $outputs['sdk']); + } + + public function testRepairPreservesUnchangedPrefixesAndSparseKeys(): void + { + $input = [ + 'before' => ['nested' => [2.5]], + 2 => 'kept', + "\0name" => 0, + 5 => ['unchanged' => [], 'bad' => NAN, 'suffix' => false], + 'after' => [0, INF], + ]; + $expected = $input; + $expected[5]['bad'] = null; + $expected['after'][1] = null; + self::assertSame(json_encode($expected), JsonEncoder::encode($input)); + self::assertNan($input[5]['bad']); + self::assertSame(INF, $input['after'][1]); + } + + public function testRepairPreservesNumericStringKeysFromSymbolTables(): void + { + $input = (static function (): array { + ${'0'} = NAN; + ${'1'} = INF; + return get_defined_vars(); + })(); + $expected = (static function (): array { + ${'0'} = null; + ${'1'} = null; + return get_defined_vars(); + })(); + self::assertSame(['0', '1'], array_keys($input)); + self::assertSame('{"0":null,"1":null}', json_encode($expected)); + self::assertSame(json_encode($expected), JsonEncoder::encode($input)); + self::assertSame(['0', '1'], array_keys($input)); + self::assertNan(array_values($input)[0]); + self::assertSame(INF, array_values($input)[1]); + } + + public static function retainedCapacityShapes(): array + { + return ['pruned root' => ['root'], 'repeated children' => ['siblings'], 'sparse high key' => ['high-key']]; + } + + #[DataProvider('retainedCapacityShapes')] + public function testRetainedArrayCapacityDoesNotExpandRepairAllocations(string $shape): void + { + $outputs = []; + foreach (['native-error', 'native-null', 'sdk'] as $mode) { + $process = proc_open( + [PHP_BINARY, '-d', 'memory_limit=32M', '-d', 'max_execution_time=10', + __DIR__ . '/fixtures/json-encoder-retained-capacity.php', + __DIR__ . '/../vendor/autoload.php', $shape, $mode], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + self::assertIsResource($process); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $mode . ': ' . $errors); + $outputs[$mode] = json_decode($output, true, 512, JSON_THROW_ON_ERROR); + if ($mode === 'native-error') { + self::assertSame(['error' => JSON_ERROR_INF_OR_NAN], $outputs[$mode]); + } + } + self::assertSame($outputs['native-null'], $outputs['sdk']); + } + + public static function depthBudgets(): array + { + return ['within budget' => [64, true], 'over budget' => [65, false]]; + } + + #[DataProvider('depthBudgets')] + public function testArrayDepthBudgetBoundary(int $depth, bool $eligible): void + { + $input = NAN; + for ($i = 0; $i < $depth; ++$i) { + $input = [$input]; + } + if ($eligible) { + self::assertSame(str_repeat('[', $depth) . 'null' . str_repeat(']', $depth), JsonEncoder::encode($input)); + } else { + self::assertFalse(JsonEncoder::encode($input)); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + } + } +} diff --git a/test/JsonEncoderReviewRegressionTest.php b/test/JsonEncoderReviewRegressionTest.php new file mode 100644 index 00000000..1e82aac3 --- /dev/null +++ b/test/JsonEncoderReviewRegressionTest.php @@ -0,0 +1,135 @@ +mutator = new class ($object) implements \JsonSerializable { + public function __construct(private object $parent) + { + } + + public function jsonSerialize(): mixed + { + $this->parent->value = 'redacted'; + return 'done'; + } + }; + return $object; + }; + $expected = '{"mutator":"done","value":"redacted"}'; + self::assertSame($expected, json_encode($make())); + self::assertTrue( + JsonEncoder::encode($make()) === $expected, + 'A backed implementation must not be read before an inherited redacting serializer.' + ); + } + + public static function cyclicSerializerModes(): array + { + return ['stateless' => [false], 'break on second invocation' => [true]]; + } + + #[DataProvider('cyclicSerializerModes')] + public function testActiveArrayDoesNotReenterItsSerializer(bool $breakOnSecondInvocation): void + { + $make = static function () use ($breakOnSecondInvocation): array { + $properties = []; + $calls = (object) ['count' => 0]; + $serializer = new class ($properties, $calls, $breakOnSecondInvocation) implements \JsonSerializable { + private array $properties; + + public function __construct( + array &$properties, + private object $calls, + private bool $breakOnSecondInvocation + ) { + $this->properties = &$properties; + } + + public function jsonSerialize(): mixed + { + ++$this->calls->count; + if ($this->breakOnSecondInvocation && $this->calls->count === 2) { + $this->properties = ['safe' => 3]; + } + return 'done'; + } + }; + $properties = ['mutator' => $serializer, 'self' => &$properties]; + return [['event' => 'cycle', 'properties' => $properties], $calls]; + }; + [$native, $nativeCalls] = $make(); + self::assertNull(\ReflectionReference::fromArrayElement($native, 'properties')); + self::assertFalse(json_encode($native)); + self::assertSame(JSON_ERROR_RECURSION, json_last_error()); + self::assertSame(1, $nativeCalls->count); + [$input, $calls] = $make(); + $actual = JsonEncoder::encode($input); + $error = json_last_error(); + self::assertFalse($actual); + self::assertSame(JSON_ERROR_RECURSION, $error); + self::assertSame(1, $calls->count); + } + + public function testHookedDateIntervalPreservesNativeViewAndCallerState(): void + { + if (PHP_VERSION_ID < 80400) { + self::markTestSkipped('Property hooks require PHP 8.4.'); + } + $object = eval(<<<'PHP' + return new class ('P1D') extends DateInterval { + public string $view { + get => 'visible'; + } + }; + PHP); + $before = json_encode($object); + self::assertSame('{"view":"visible"}', $before); + $actual = JsonEncoder::encode($object); + $after = json_encode($object); + self::assertSame($before, $actual); + self::assertSame($before, $after, 'Encoding must not materialize interval fields on the caller object.'); + } + + public function testLargeStringMatchesNativeWithin32MbBeforeBatchSizeRejection(): void + { + $outputs = []; + foreach (['native', 'sdk'] as $mode) { + $process = proc_open( + [PHP_BINARY, '-d', 'memory_limit=32M', '-d', 'max_execution_time=10', + __DIR__ . '/fixtures/json-encoder-large-string.php', __DIR__ . '/../vendor/autoload.php', $mode], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + self::assertIsResource($process); + fclose($pipes[0]); + $outputs[$mode] = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $mode . ': ' . $errors); + } + self::assertSame($outputs['native'], $outputs['sdk']); + } +} diff --git a/test/JsonEncoderTest.php b/test/JsonEncoderTest.php new file mode 100644 index 00000000..e8e641bf --- /dev/null +++ b/test/JsonEncoderTest.php @@ -0,0 +1,1054 @@ + [false], 'original non-finite values' => [true]]; + } + + #[DataProvider('supportedObjectModes')] + public function testSupportedObjectRepresentationsAndReferencesArePreserved(bool $nonFinite): void + { + $number = $nonFinite ? INF : 2.5; + $input = [ + 'reference' => &$number, + 'date' => new \DateTimeImmutable('2024-01-01T00:00:00Z'), + 'arrayObject' => new \ArrayObject(['number' => $nonFinite ? NAN : $number]), + 'arrayIterator' => new \ArrayIterator(['number' => $number]), + 'timezone' => new \DateTimeZone('UTC'), + 'dateSubclass' => new class ('2024-01-01T00:00:00Z') extends \DateTimeImmutable { + public string $label = 'kept'; + private float $hidden = INF; + }, + 'arrayObjectSubclass' => new class (['number' => $number]) extends \ArrayObject { + public string $ignored = 'ignored'; + }, + 'emptyObject' => new \stdClass(), + 'emptyArray' => [], + 'sparseArray' => [2 => $number, 4 => 'kept'], + ]; + $expected = json_encode($input); + $error = json_last_error(); + if ($nonFinite) { + self::assertFalse($expected); + } else { + self::assertIsString($expected); + } + self::assertSame($expected, JsonEncoder::encode($input)); + self::assertSame($error, json_last_error()); + self::assertSame($nonFinite ? INF : 2.5, $number); + } + + public static function nativeArrayFlagOverrides(): array + { + return [ + 'ArrayObject wrong flags' => ['ArrayObject', 'return self::STD_PROP_LIST;'], + 'ArrayIterator wrong flags' => ['ArrayIterator', 'return self::STD_PROP_LIST;'], + 'ArrayObject throwing flags' => ['ArrayObject', 'throw new LogicException("unexpected override");'], + 'ArrayIterator throwing flags' => ['ArrayIterator', 'throw new LogicException("unexpected override");'], + ]; + } + + #[DataProvider('nativeArrayFlagOverrides')] + public function testNativeArrayFlagOverridesAreNotInvoked(string $parent, string $override): void + { + $object = eval(sprintf(<<<'PHP' + return new class (['stored' => 7]) extends %s { + public string $excluded = 'private fixture'; + private int $overrideCalls = 0; + + public function getFlags(): int + { + ++$this->overrideCalls; + %s + } + + public function overrideCalls(): int + { + return $this->overrideCalls; + } + }; + PHP, $parent, $override)); + self::assertSame('{"stored":7}', json_encode($object)); + self::assertSame(0, $object->overrideCalls()); + self::assertTrue( + JsonEncoder::encode($object) === '{"stored":7}', + 'Native flags must preserve storage and exclude ignored public properties.' + ); + self::assertSame(0, $object->overrideCalls()); + } + + public static function nativeArrayParents(): array + { + return ['ArrayObject' => ['ArrayObject'], 'ArrayIterator' => ['ArrayIterator']]; + } + + #[DataProvider('nativeArrayParents')] + public function testNativeStandardPropertyFlagsPreserveHooksDespiteOverride(string $parent): void + { + if (PHP_VERSION_ID < 80400) { + self::markTestSkipped('Property hooks require PHP 8.4.'); + } + $object = eval(sprintf(<<<'PHP' + return new class (['stored' => 7]) extends %s { + public string $value = 'private fixture' { + get => $this->value === '' ? '' : 'filtered'; + } + public string $virtual { + get => 'visible'; + } + public function getFlags(): int + { + return 0; + } + }; + PHP, $parent)); + $object->setFlags(\ArrayObject::STD_PROP_LIST); + $expected = ['value' => 'filtered', 'virtual' => 'visible']; + self::assertTrue(json_decode(json_encode($object), true, 512, JSON_THROW_ON_ERROR) === $expected); + self::assertTrue( + json_decode(JsonEncoder::encode($object), true, 512, JSON_THROW_ON_ERROR) === $expected, + 'Genuine native STD_PROP_LIST must retain the getter-filtered public view.' + ); + } + + public function testReferencedNonFiniteArraySlotKeepsNativeFailureWithoutMutatingCaller(): void + { + $number = INF; + $input = ['prefix' => 1, 'items' => [0, &$number, 2], 'suffix' => false]; + self::assertFalse(json_encode($input)); + $error = json_last_error(); + self::assertFalse(JsonEncoder::encode($input)); + self::assertSame($error, json_last_error()); + self::assertSame(INF, $number); + self::assertSame([0, INF, 2], $input['items']); + } + + public function testCustomSerializerRunsOnceBeforeLaterObjectValuesAreRead(): void + { + $createInput = static function (float $next): array { + $object = (object) ['number' => 1]; + $calls = (object) ['count' => 0]; + $serializer = new class ($object, $calls, $next) implements \JsonSerializable { + public function __construct(private object $object, private object $calls, private float $next) + { + } + + public function jsonSerialize(): mixed + { + ++$this->calls->count; + $this->object->number = $this->next; + return 'done'; + } + }; + return [['mutator' => $serializer, 'object' => $object], $calls]; + }; + [$nativeInput, $nativeCalls] = $createInput(4); + self::assertSame('{"mutator":"done","object":{"number":4}}', json_encode($nativeInput)); + self::assertSame(1, $nativeCalls->count); + [$finiteInput, $finiteCalls] = $createInput(4); + self::assertSame('{"mutator":"done","object":{"number":4}}', JsonEncoder::encode($finiteInput)); + self::assertSame(1, $finiteCalls->count); + [$input, $calls] = $createInput(NAN); + self::assertFalse(JsonEncoder::encode($input)); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + self::assertSame(1, $calls->count); + self::assertNan($input['object']->number); + } + + public static function declaredPropertyChanges(): array + { + return [ + 'ordinary class redaction' => ['', 'redacted'], + 'stdClass subclass redaction' => [' extends \\stdClass', 'redacted'], + 'Exception subclass redaction' => [' extends \\Exception', 'redacted'], + 'ordinary class non-finite update' => ['', NAN], + ]; + } + + #[DataProvider('declaredPropertyChanges')] + public function testDeclaredPropertyIsReadAfterEarlierSerializer(string $parent, mixed $next): void + { + $make = static function () use ($parent, $next): object { + $object = eval(sprintf(<<<'PHP' + return new class%s { + public mixed $mutator; + public mixed $value = 'secret'; + }; + PHP, $parent)); + $object->mutator = new class ($object, $next) implements \JsonSerializable { + public int $calls = 0; + + public function __construct(private object $parent, private mixed $next) + { + } + + public function jsonSerialize(): mixed + { + ++$this->calls; + $this->parent->value = $this->next; + return 'done'; + } + }; + return $object; + }; + $native = json_encode($make()); + if (is_float($next)) { + self::assertFalse($native); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + $expected = false; + } else { + $expected = '{"mutator":"done","value":"redacted"}'; + self::assertSame($expected, $native); + } + $object = $make(); + self::assertTrue( + JsonEncoder::encode($object) === $expected, + 'Declared properties must be read after earlier serializers have run.' + ); + self::assertSame(1, $object->mutator->calls); + self::assertNull(\ReflectionReference::fromArrayElement((array) $object, 'value')); + } + + public static function declaredPropertyPresenceChanges(): array + { + return [ + 'remove' => [false, false], + 'remove with magic getter' => [false, true], + 'initialize' => [true, false], + 'initialize with magic getter' => [true, true], + ]; + } + + #[DataProvider('declaredPropertyPresenceChanges')] + public function testDeclaredPropertyPresenceMatchesNativeEncoding(bool $initialize, bool $magic): void + { + $make = static function () use ($initialize, $magic): object { + $getter = $magic + ? 'public function __get(string $name): mixed { throw new LogicException("magic read"); }' + : ''; + $object = eval(sprintf(<<<'PHP' + return new class { + public mixed $mutator; + public string $value; + public int $after = 7; + %s + }; + PHP, $getter)); + if (!$initialize) { + $object->value = 'before'; + } + $object->mutator = new class ($object, $initialize) implements \JsonSerializable { + public int $calls = 0; + + public function __construct(private object $parent, private bool $initialize) + { + } + + public function jsonSerialize(): mixed + { + ++$this->calls; + if ($this->initialize) { + $this->parent->value = 'ready'; + } else { + unset($this->parent->value); + } + return 'done'; + } + }; + return $object; + }; + $expected = $initialize + ? '{"mutator":"done","value":"ready","after":7}' + : '{"mutator":"done","after":7}'; + self::assertSame($expected, json_encode($make())); + $object = $make(); + self::assertSame($expected, JsonEncoder::encode($object)); + self::assertSame(1, $object->mutator->calls); + } + + public static function referencedScalarChanges(): array + { + $changes = [ + 'finite to finite' => [1, 2, '2'], + 'finite to NaN' => [1, NAN, null], + 'finite to positive infinity' => [1, INF, null], + 'finite to negative infinity' => [1, -INF, null], + 'NaN to finite' => [NAN, 2, '2'], + 'positive infinity to finite' => [INF, 2, '2'], + 'negative infinity to finite' => [-INF, 2, '2'], + ]; + $cases = []; + foreach (['array', 'stdClass', 'ArrayObject', 'ArrayObject public view'] as $container) { + foreach ($changes as $label => $values) { + $cases[$container . ' ' . $label] = [$container, ...$values]; + } + } + return $cases; + } + + #[DataProvider('referencedScalarChanges')] + public function testReferencedScalarIsReadAfterEarlierSerializer( + string $container, + int|float $initial, + int|float $next, + ?string $expectedNumber + ): void { + $encode = static function (bool $native) use ($container, $initial, $next): array { + $number = $initial; + $calls = 0; + $serializer = new class ($number, $calls, $next) implements \JsonSerializable { + private int|float $number; + private int $calls; + + public function __construct(int|float &$number, int &$calls, private int|float $next) + { + $this->number = &$number; + $this->calls = &$calls; + } + + public function jsonSerialize(): mixed + { + ++$this->calls; + $this->number = $this->next; + return null; + } + }; + $fields = ['mutator' => $serializer, 'n' => &$number, 'after' => 7]; + $input = match ($container) { + 'array' => $fields, + 'stdClass' => (object) $fields, + 'ArrayObject' => new \ArrayObject($fields), + 'ArrayObject public view' => new class extends \ArrayObject { + public mixed $mutator; + public mixed $n; + public int $after = 7; + }, + }; + if ($container === 'ArrayObject public view') { + $input->setFlags(\ArrayObject::STD_PROP_LIST); + $input->mutator = $serializer; + $input->n = &$number; + } + $before = is_array($input) ? $input : (array) $input; + $referenceId = \ReflectionReference::fromArrayElement($before, 'n')->getId(); + $json = $native ? json_encode($input) : JsonEncoder::encode($input); + $error = json_last_error(); + $after = is_array($input) ? $input : (array) $input; + self::assertSame(['mutator', 'n', 'after'], array_keys($after)); + self::assertSame($referenceId, \ReflectionReference::fromArrayElement($after, 'n')->getId()); + self::assertNull(\ReflectionReference::fromArrayElement($after, 'mutator')); + self::assertNull(\ReflectionReference::fromArrayElement($after, 'after')); + self::assertSame(1, $calls); + if (is_nan($next)) { + self::assertNan($number); + } else { + self::assertSame($next, $number); + } + return [$json, $error]; + }; + [$native, $error] = $encode(true); + if (is_finite($next)) { + self::assertSame('{"mutator":null,"n":2,"after":7}', $native); + } else { + self::assertFalse($native); + self::assertSame(JSON_ERROR_INF_OR_NAN, $error); + } + [$actual, $actualError] = $encode(false); + self::assertSame($native, $actual); + self::assertSame($error, $actualError); + if ($expectedNumber === null) { + self::assertFalse($actual); + } else { + self::assertSame('{"mutator":null,"n":' . $expectedNumber . ',"after":7}', $actual); + } + } + + public function testSharedReferenceIsReadAtEachWirePosition(): void + { + $number = 1; + $serializer = new class ($number) implements \JsonSerializable { + private int $number; + + public function __construct(int &$number) + { + $this->number = &$number; + } + + public function jsonSerialize(): mixed + { + $this->number = 2; + return null; + } + }; + $input = ['first' => &$number, 'mutator' => $serializer, 'last' => &$number]; + self::assertSame('{"first":1,"mutator":null,"last":2}', JsonEncoder::encode($input)); + self::assertSame(2, $number); + self::assertSame( + \ReflectionReference::fromArrayElement($input, 'first')->getId(), + \ReflectionReference::fromArrayElement($input, 'last')->getId() + ); + } + + public function testNativeSubclassSerializerRemainsDeferred(): void + { + $object = (object) ['number' => 1]; + $calls = (object) ['count' => 0]; + $serializer = new class ($object, $calls) extends \ArrayObject implements \JsonSerializable { + public function __construct(private object $object, private object $calls) + { + parent::__construct(['stored' => 7]); + } + + public function jsonSerialize(): mixed + { + ++$this->calls->count; + $this->object->number = NAN; + return 'done'; + } + + public function getFlags(): int + { + throw new \LogicException('must not dispatch'); + } + }; + self::assertFalse(JsonEncoder::encode(['mutator' => $serializer, 'object' => $object])); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + self::assertSame(1, $calls->count); + self::assertNan($object->number); + } + + public function testOpaqueDataObjectsKeepNativeFailureWithoutMutatingInput(): void + { + $object = (object) [ + 'number' => NAN, + 'nested' => (object) ['number' => INF], + 'items' => [-INF, 0], + 'finite' => 2.5, + 'literal' => 'NaN', + ]; + self::assertFalse(json_encode($object)); + $error = json_last_error(); + self::assertFalse(JsonEncoder::encode($object)); + self::assertSame($error, json_last_error()); + self::assertNan($object->number); + self::assertSame(INF, $object->nested->number); + self::assertSame([-INF, 0], $object->items); + } + + public function testInheritedNativePublicPropertiesKeepTheirWireOrder(): void + { + if (!class_exists(\PDOException::class)) { + self::markTestSkipped('PDOException requires the PDO extension.'); + } + $value = new class extends \PDOException { + public string $value = 'kept'; + }; + $value->errorInfo = ['HY000']; + $expected = '{"errorInfo":["HY000"],"value":"kept"}'; + self::assertSame($expected, json_encode($value)); + self::assertSame($expected, JsonEncoder::encode($value)); + } + + public function testNativeVirtualPropertyHandlersArePreserved(): void + { + if (!class_exists(\BcMath\Number::class)) { + self::markTestSkipped('BcMath Number requires PHP 8.4 and the bcmath extension.'); + } + $value = new \BcMath\Number('1.23'); + self::assertSame('{"value":"1.23","scale":2}', json_encode($value)); + self::assertSame(json_encode($value), JsonEncoder::encode($value)); + } + + public function testSimpleXmlSubclassesKeepTheirNativePropertyView(): void + { + if (!class_exists(\SimpleXMLElement::class)) { + self::markTestSkipped('SimpleXML extension is required.'); + } + $object = new class ('') extends \SimpleXMLElement { + public string $label; + }; + self::assertSame('{"label":"A"}', json_encode($object)); + self::assertSame('{"label":"A"}', JsonEncoder::encode($object)); + } + + public static function replacementArrays(): array + { + return [ + 'map' => [['safe' => 3]], + 'empty list' => [[]], + 'nested list' => [[['safe' => 3]]], + ]; + } + + #[DataProvider('replacementArrays')] + public function testReplacedReferencedArrayIsNotTreatedAsTheActiveArray(array $replacement): void + { + $make = static function () use ($replacement): array { + $root = []; + $serializer = new class ($root, $replacement) implements \JsonSerializable { + private array $root; + public int $calls = 0; + + public function __construct(array &$root, private array $replacement) + { + $this->root = &$root; + } + + public function jsonSerialize(): mixed + { + ++$this->calls; + $this->root = $this->replacement; + return 'done'; + } + }; + $root = ['mutator' => $serializer, 'later' => &$root]; + return [['root' => &$root], $serializer]; + }; + [$native] = $make(); + $expected = json_encode($native); + self::assertIsString($expected); + [$input, $serializer] = $make(); + self::assertSame($expected, JsonEncoder::encode($input)); + self::assertSame(JSON_ERROR_NONE, json_last_error()); + self::assertSame(1, $serializer->calls); + self::assertSame($replacement, $input['root']); + } + + public function testOuterReferenceRemainsActiveAfterTemporaryReplacement(): void + { + $make = static function (): array { + $root = []; + $replace = new class ($root) implements \JsonSerializable { + private array $root; + public array $replacement = ['safe' => 3]; + public int $calls = 0; + + public function __construct(array &$root) + { + $this->root = &$root; + } + + public function jsonSerialize(): mixed + { + ++$this->calls; + $this->root = $this->replacement; + return 'done'; + } + }; + $restore = clone $replace; + $root = ['replace' => $replace, 'first' => &$root, 'restore' => $restore, 'last' => &$root]; + $restore->replacement = $root; + return [['root' => &$root], $replace, $restore]; + }; + [$native] = $make(); + self::assertFalse(json_encode($native)); + self::assertSame(JSON_ERROR_RECURSION, json_last_error()); + [$input, $replace, $restore] = $make(); + self::assertFalse(JsonEncoder::encode($input)); + self::assertSame(JSON_ERROR_RECURSION, json_last_error()); + self::assertSame(1, $replace->calls); + self::assertSame(1, $restore->calls); + } + + public function testClosuresKeepTheirNativeEmptyObjectRepresentation(): void + { + $invoked = false; + $callback = static function () use (&$invoked): void { + $invoked = true; + }; + $input = ['callback' => $callback]; + $expected = '{"callback":{}}'; + + self::assertSame($expected, json_encode($input)); + self::assertSame($expected, JsonEncoder::encode($input)); + self::assertSame($callback, $input['callback']); + self::assertFalse($invoked); + } + + public function testPreservesPublicPropertyNamesContainingEmbeddedNullBytes(): void + { + $object = (object) ["a\0b" => 1]; + $expected = '{"a\u0000b":1}'; + + self::assertSame($expected, json_encode($object)); + self::assertSame($expected, JsonEncoder::encode($object)); + self::assertSame(1, $object->{"a\0b"}); + } + + public static function propertyHookParents(): array + { + return [ + 'ordinary class' => [''], + 'stdClass subclass' => [' extends \\stdClass'], + 'Exception subclass' => [' extends \\Exception'], + 'Error subclass' => [' extends \\Error'], + 'SplObjectStorage subclass' => [' extends \\SplObjectStorage'], + 'ArrayObject with standard properties' => [' extends \\ArrayObject'], + 'ArrayIterator with standard properties' => [' extends \\ArrayIterator'], + ]; + } + + public static function propertyHookPayloads(): array + { + $cases = []; + foreach (self::propertyHookParents() as $name => [$parent]) { + $cases[$name . ' finite'] = [$parent, false]; + $cases[$name . ' original non-finite'] = [$parent, true]; + } + return $cases; + } + + #[DataProvider('propertyHookPayloads')] + public function testPropertyHooksControlTheSerializedPublicView(string $parentDeclaration, bool $nonFinite): void + { + if (PHP_VERSION_ID < 80400) { + self::markTestSkipped('Property hooks require PHP 8.4.'); + } + $object = eval(sprintf(<<<'PHP' + return new class%s { + private int $getterCalls = 0; + public string $value = 'private fixture' { + get { + ++$this->getterCalls; + return $this->value === '' ? '' : 'filtered'; + } + } + public string $virtual { + get => 'visible'; + } + public function getterCalls(): int + { + return $this->getterCalls; + } + }; + PHP, $parentDeclaration)); + if ($object instanceof \ArrayObject || $object instanceof \ArrayIterator) { + $object->setFlags(\ArrayObject::STD_PROP_LIST); + } + $native = json_decode(json_encode($object), true, 512, JSON_THROW_ON_ERROR); + self::assertTrue($native === ['value' => 'filtered', 'virtual' => 'visible']); + self::assertSame(1, $object->getterCalls()); + $encoded = JsonEncoder::encode(['number' => $nonFinite ? NAN : 2.5, 'object' => $object]); + if ($nonFinite) { + self::assertFalse($encoded); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + } else { + $decoded = json_decode($encoded, true, 512, JSON_THROW_ON_ERROR); + self::assertTrue( + $decoded['object'] === ['value' => 'filtered', 'virtual' => 'visible'], + 'Serialization must use the public property getters, not backing storage.' + ); + self::assertSame(2.5, $decoded['number']); + } + self::assertSame(2, $object->getterCalls()); + } + + #[DataProvider('propertyHookParents')] + public function testPropertyGetterExceptionsAreNotSwallowed(string $parentDeclaration): void + { + if (PHP_VERSION_ID < 80400) { + self::markTestSkipped('Property hooks require PHP 8.4.'); + } + $object = eval(sprintf(<<<'PHP' + return new class%s { + public string $value { + get => throw new LogicException('unsupported getter'); + } + }; + PHP, $parentDeclaration)); + if ($object instanceof \ArrayObject || $object instanceof \ArrayIterator) { + $object->setFlags(\ArrayObject::STD_PROP_LIST); + } + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('unsupported getter'); + JsonEncoder::encode($object); + } + + public function testHookedPropertyIsReadAfterEarlierSerializer(): void + { + if (PHP_VERSION_ID < 80400) { + self::markTestSkipped('Property hooks require PHP 8.4.'); + } + $make = static function (): object { + $object = eval(<<<'PHP' + return new class { + public mixed $mutator; + public string $value = 'before' { + get => strtoupper($this->value); + } + }; + PHP); + $object->mutator = new class ($object) implements \JsonSerializable { + public function __construct(private object $parent) + { + } + + public function jsonSerialize(): mixed + { + $this->parent->value = 'after'; + return 'done'; + } + }; + return $object; + }; + $expected = '{"mutator":"done","value":"AFTER"}'; + self::assertSame($expected, json_encode($make())); + $object = $make(); + self::assertSame($expected, JsonEncoder::encode($object)); + self::assertSame('AFTER', $object->value); + } + + public function testScalarEncodingDoesNotResetErrorStateSeenByCallbacks(): void + { + $serializer = new class implements \JsonSerializable { + public function jsonSerialize(): mixed + { + return json_last_error(); + } + }; + json_decode('{'); + $expected = json_encode(['serializer' => $serializer]); + json_decode('{'); + self::assertSame($expected, JsonEncoder::encode(['serializer' => $serializer])); + self::assertSame(JSON_ERROR_NONE, json_last_error()); + } + + public function testMalformedKeysRetainErrorsAndNativeCallbackOrdering(): void + { + $serializer = new class implements \JsonSerializable { + public int $calls = 0; + + public function jsonSerialize(): mixed + { + ++$this->calls; + return 7; + } + }; + self::assertFalse(json_encode(["\xB1" => $serializer])); + self::assertSame(JSON_ERROR_UTF8, json_last_error()); + self::assertSame(1, $serializer->calls); + self::assertFalse(JsonEncoder::encode(["\xB1" => $serializer])); + self::assertSame(JSON_ERROR_UTF8, json_last_error()); + self::assertSame(2, $serializer->calls); + self::assertSame('7', JsonEncoder::encode(7)); + self::assertSame(JSON_ERROR_NONE, json_last_error()); + } + + public function testDepthBoundariesMatchNativeErrors(): void + { + foreach ([511, 512, 513] as $depth) { + $value = 0; + for ($i = 0; $i < $depth; ++$i) { + $value = [$value]; + } + $expected = json_encode($value); + $error = json_last_error(); + self::assertSame($expected, JsonEncoder::encode($value)); + self::assertSame($error, json_last_error()); + } + } + + public function testUnsupportedValuesStillFailEncoding(): void + { + $resource = fopen('php://memory', 'r'); + try { + self::assertFalse(JsonEncoder::encode(['number' => NAN, 'unsupported' => $resource])); + self::assertSame(JSON_ERROR_UNSUPPORTED_TYPE, json_last_error()); + self::assertFalse(JsonEncoder::encode(['number' => INF, 'invalid' => "\xB1\x31"])); + self::assertSame(JSON_ERROR_UTF8, json_last_error()); + } finally { + fclose($resource); + } + } + + public function testJsonSerializableExceptionsAreNotSwallowed(): void + { + $value = new class implements \JsonSerializable { + public function jsonSerialize(): mixed + { + throw new \LogicException('unsupported serialization'); + } + }; + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('unsupported serialization'); + JsonEncoder::encode($value); + } + + public function testUserJsonExceptionsAreNotConvertedIntoEncodingFailures(): void + { + $value = new class implements \JsonSerializable { + public function jsonSerialize(): mixed + { + throw new \JsonException('user serialization exception'); + } + }; + $this->expectException(\JsonException::class); + $this->expectExceptionMessage('user serialization exception'); + JsonEncoder::encode($value); + } + + public static function siblingContainers(): array + { + $cases = [ + 'empty arrays' => ['empty-arrays', 300000, false], + 'shared finite scalar arrays' => ['shared-scalar-arrays', 200000, false], + 'distinct finite scalar arrays' => ['distinct-scalar-arrays', 100000, false], + 'shared empty object' => ['shared-empty-object', 300000, false], + 'distinct empty objects' => ['distinct-empty-objects', 200000, false], + 'distinct finite scalar objects' => ['distinct-scalar-objects', 50000, false], + 'declared-only objects' => ['declared-scalar-objects', 100000, false], + 'declared objects with non-finite sibling' => ['declared-scalar-objects', 100000, true], + 'dense arrays with non-finite sibling' => ['shared-scalar-arrays', 200000, true], + 'dense objects with non-finite sibling' => ['distinct-empty-objects', 200000, true], + 'dense objects with DateTime' => ['distinct-empty-objects', 200000, true, 'DateTime'], + 'dense objects with DateTimeImmutable' => ['distinct-empty-objects', 200000, true, 'DateTimeImmutable'], + 'dense objects with DateTimeZone' => ['distinct-empty-objects', 200000, true, 'DateTimeZone'], + 'dense objects with ArrayObject storage' => ['distinct-empty-objects', 200000, true, 'ArrayObject'], + 'dense objects with ArrayIterator storage' => ['distinct-empty-objects', 200000, true, 'ArrayIterator'], + 'ArrayObject public sibling' => ['distinct-empty-objects', 200000, true, 'ArrayObject-properties'], + 'ArrayIterator public sibling' => ['distinct-empty-objects', 200000, true, 'ArrayIterator-properties'], + ]; + foreach ($cases as $name => $case) { + if (isset($case[3])) { + $case[2] = false; + $cases[$name . ' finite'] = $case; + } + } + return $cases; + } + + #[DataProvider('siblingContainers')] + public function testSiblingContainersFitWithinNativeMemoryLimit( + string $shape, + int $count, + bool $includeNonFinite, + string $nativeType = '' + ): void { + $native = $this->runSiblingMemoryProbe($shape, $count, 'native', $includeNonFinite, $nativeType); + self::assertSame(0, $native['exit'], 'Native baseline failed: ' . $native['stderr']); + $encoded = $this->runSiblingMemoryProbe($shape, $count, 'sdk', $includeNonFinite, $nativeType); + self::assertSame(0, $encoded['exit'], 'SDK encoding failed: ' . $encoded['stderr']); + $nativeLines = explode("\n", trim($native['stdout'])); + $encodedLines = explode("\n", trim($encoded['stdout'])); + $expected = json_decode(end($nativeLines), true, 512, JSON_THROW_ON_ERROR); + $actual = json_decode(end($encodedLines), true, 512, JSON_THROW_ON_ERROR); + if ($includeNonFinite) { + self::assertSame(['error' => JSON_ERROR_INF_OR_NAN], $expected); + } else { + self::assertLessThan(1024 * 1024, $expected['bytes']); + } + self::assertSame($expected, $actual); + } + + private function runSiblingMemoryProbe( + string $shape, + int $count, + string $mode, + bool $includeNonFinite, + string $nativeType + ): array { + $process = proc_open( + [PHP_BINARY, '-d', 'memory_limit=32M', '-d', 'max_execution_time=10', + __DIR__ . '/fixtures/json-encoder-siblings.php', __DIR__ . '/../vendor/autoload.php', + $shape, (string) $count, $mode, $includeNonFinite ? '1' : '0', $nativeType], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + self::assertIsResource($process); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + return ['exit' => proc_close($process), 'stdout' => $output, 'stderr' => $errors]; + } + + public static function largePayloadShapes(): array + { + return ['sibling allocation' => ['siblings', 10], 'reference traversal' => ['references', 5]]; + } + + #[DataProvider('largePayloadShapes')] + public function testLargePayloadEncodingMatchesNativeWithinResourceLimits(string $shape, int $seconds): void + { + $outputs = []; + foreach (['native', 'sdk'] as $mode) { + $process = proc_open( + [PHP_BINARY, '-d', 'memory_limit=128M', '-d', 'max_execution_time=' . $seconds, + __DIR__ . '/fixtures/json-encoder-scale.php', __DIR__ . '/../vendor/autoload.php', $shape, $mode], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + self::assertIsResource($process); + fclose($pipes[0]); + $outputs[$mode] = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $mode . ': ' . $errors); + } + self::assertSame($outputs['native'], $outputs['sdk']); + } + + public function testTemporarySerializedObjectsFitWithinMemoryLimit(): void + { + $code = <<<'PHP' + require $argv[1]; + $event = new class implements JsonSerializable { + public function jsonSerialize(): mixed + { + $items = []; + for ($i = 0; $i < 1000; ++$i) { + $items[] = (object) ['value' => $i]; + } + return $items; + } + }; + $batch = array_fill(0, 100, $event); + $native = json_encode($batch); + if ($native === false || PostHog\Internal\JsonEncoder::encode($batch) !== $native) { + exit(1); + } + echo 'encoded'; + PHP; + $process = proc_open( + [PHP_BINARY, '-d', 'memory_limit=64M', '-d', 'max_execution_time=10', '-r', $code, + __DIR__ . '/../vendor/autoload.php'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + self::assertIsResource($process); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $errors); + self::assertSame('encoded', $output); + } + + public function testInvalidUtf8DoesNotChangeSerializerInvocationOrder(): void + { + $serializer = new class implements \JsonSerializable { + public int $calls = 0; + + public function jsonSerialize(): mixed + { + ++$this->calls; + return 'kept'; + } + }; + self::assertFalse(JsonEncoder::encode(['invalid' => "\xB1", 'serializer' => $serializer])); + self::assertSame(JSON_ERROR_UTF8, json_last_error()); + self::assertSame(0, $serializer->calls); + self::assertFalse(JsonEncoder::encode(['serializer' => $serializer, 'invalid' => "\xB1"])); + self::assertSame(JSON_ERROR_UTF8, json_last_error()); + self::assertSame(1, $serializer->calls); + } + + public function testSharedInvalidArrayGraphIsRejectedWithoutEagerExpansion(): void + { + $code = <<<'PHP' + require $argv[1]; + $value = ["\xB1"]; + for ($i = 0; $i < 24; ++$i) { + $value = [$value, $value]; + } + if (json_encode($value) !== false || json_last_error() !== JSON_ERROR_UTF8) { + exit(1); + } + if (PostHog\Internal\JsonEncoder::encode($value) !== false || json_last_error() !== JSON_ERROR_UTF8) { + exit(2); + } + echo 'rejected'; + PHP; + $process = proc_open( + [PHP_BINARY, '-d', 'memory_limit=32M', '-d', 'max_execution_time=2', '-r', $code, + __DIR__ . '/../vendor/autoload.php'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + self::assertIsResource($process); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $errors); + self::assertSame('rejected', $output); + } + + public function testRecursiveAndOverDepthInputFailsInBoundedSubprocess(): void + { + $code = <<<'PHP' + require $argv[1]; + $array = []; + $array['self'] = &$array; + $array['again'] = &$array; + $object = new stdClass(); + $object->self = $object; + $first = new class implements JsonSerializable { + public object $next; + + public function jsonSerialize(): mixed + { + return $this->next; + } + }; + $second = clone $first; + $first->next = $second; + $second->next = $first; + if (json_encode($first) !== false || json_last_error() !== JSON_ERROR_RECURSION) { + exit(2); + } + if (PostHog\Internal\JsonEncoder::encode($first) !== false || json_last_error() !== JSON_ERROR_RECURSION) { + exit(3); + } + $wideObject = new stdClass(); + $wideArray = []; + for ($i = 0; $i < 10000; ++$i) { + $wideObject->{'p' . $i} = $i; + $wideArray['p' . $i] = $i; + } + $wideObject->self = $wideObject; + $wideArray['self'] = &$wideArray; + foreach ([$wideObject, $wideArray] as $value) { + if (PostHog\Internal\JsonEncoder::encode($value) !== false || json_last_error() !== JSON_ERROR_RECURSION) { + exit(4); + } + } + $deep = ['number' => INF]; + for ($i = 0; $i < 513; ++$i) { + $deep = [$deep]; + } + foreach ([$array, $object, $deep] as $value) { + if (PostHog\Internal\JsonEncoder::encode($value) !== false) { + exit(1); + } + } + echo 'rejected'; + PHP; + $process = proc_open( + [PHP_BINARY, '-d', 'memory_limit=32M', '-d', 'max_execution_time=3', '-r', $code, + __DIR__ . '/../vendor/autoload.php'], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + self::assertIsResource($process); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $errors = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + self::assertSame(0, proc_close($process), $errors); + self::assertSame('rejected', $output); + } +} diff --git a/test/NonFiniteJsonSerializableEnum.php b/test/NonFiniteJsonSerializableEnum.php new file mode 100644 index 00000000..21fff121 --- /dev/null +++ b/test/NonFiniteJsonSerializableEnum.php @@ -0,0 +1,25 @@ + match ($this) { + self::NotANumber => NAN, + self::PositiveInfinity => INF, + self::NegativeInfinity => -INF, + }, + 'finite' => 2.5, + 'literal' => 'NaN', + ]; + } +} diff --git a/test/NonFinitePropertiesTest.php b/test/NonFinitePropertiesTest.php new file mode 100644 index 00000000..94ba0b6d --- /dev/null +++ b/test/NonFinitePropertiesTest.php @@ -0,0 +1,333 @@ + ['lib_curl', NAN], + 'batch positive infinity' => ['lib_curl', INF], + 'batch negative infinity' => ['lib_curl', -INF], + 'file NaN' => ['file', NAN], + 'file positive infinity' => ['file', INF], + 'file negative infinity' => ['file', -INF], + ]; + } + + public static function serializableEnums(): array + { + return [ + 'NaN' => [NonFiniteJsonSerializableEnum::NotANumber], + 'positive infinity' => [NonFiniteJsonSerializableEnum::PositiveInfinity], + 'negative infinity' => [NonFiniteJsonSerializableEnum::NegativeInfinity], + ]; + } + + #[DataProvider('serializableEnums')] + public function testCaptureKeepsNativeFailureForNonFiniteSerializableEnums( + NonFiniteJsonSerializableEnum $enum + ): void { + $httpClient = new MockedHttpClient('unused.invalid'); + $client = new Client('test-key', ['batch_size' => 10, 'flush_interval_seconds' => 3600], $httpClient); + $properties = ['enum' => $enum, 'enabled' => false]; + try { + self::assertTrue($client->capture([ + 'distinctId' => 'user', 'event' => 'enum-event', 'properties' => $properties, + ])); + self::assertTrue($client->capture([ + 'distinctId' => 'user', 'event' => 'neighbor', 'properties' => ['finite' => 7], + ])); + json_encode(null); + self::assertFalse($client->flush()); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + self::assertNull($httpClient->calls); + self::assertSame(['enum' => $enum, 'enabled' => false], $properties); + } finally { + $client->shutdown(); + } + } + + public static function closureModes(): array + { + return ['finite numbers' => [false], 'original non-finite numbers' => [true]]; + } + + #[DataProvider('closureModes')] + public function testCaptureKeepsNativeClosureBehavior(bool $nonFinite): void + { + $invoked = false; + $callback = static function () use (&$invoked): void { + $invoked = true; + }; + $numbers = $nonFinite ? [NAN, INF, -INF] : [1.5, 2.5, -2.5]; + $properties = [ + 'callback' => $callback, + 'numbers' => $numbers, + 'finite' => 2.5, + 'enabled' => false, + 'literal' => 'NaN', + ]; + $httpClient = new MockedHttpClient('unused.invalid'); + $client = new Client('test-key', ['batch_size' => 10, 'flush_interval_seconds' => 3600], $httpClient); + try { + self::assertTrue($client->capture([ + 'distinctId' => 'user', 'event' => 'callback-event', 'properties' => $properties, + ])); + self::assertTrue($client->capture([ + 'distinctId' => 'user', 'event' => 'neighbor', 'properties' => ['finite' => 7], + ])); + if ($nonFinite) { + json_encode(null); + self::assertFalse($client->flush()); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + self::assertNull($httpClient->calls); + self::assertNan($properties['numbers'][0]); + self::assertSame(INF, $properties['numbers'][1]); + self::assertSame(-INF, $properties['numbers'][2]); + } else { + self::assertTrue($client->flush()); + self::assertCount(1, $httpClient->calls); + $payload = json_decode($httpClient->calls[0]['payload'], false, 512, JSON_THROW_ON_ERROR); + self::assertSame(['callback-event', 'neighbor'], array_column($payload->batch, 'event')); + $actual = $payload->batch[0]->properties; + self::assertInstanceOf(\stdClass::class, $actual->callback); + self::assertSame([], (array) $actual->callback); + self::assertSame($numbers, $actual->numbers); + self::assertSame(2.5, $actual->finite); + self::assertFalse($actual->enabled); + self::assertSame('NaN', $actual->literal); + self::assertSame(7, $payload->batch[1]->properties->finite); + } + self::assertSame($callback, $properties['callback']); + self::assertFalse($invoked); + } finally { + $client->shutdown(); + } + } + + public static function propertyHookParents(): array + { + return [ + 'stdClass finite' => ['stdClass', false], + 'stdClass original non-finite' => ['stdClass', true], + 'Exception finite' => ['Exception', false], + 'Exception original non-finite' => ['Exception', true], + ]; + } + + #[DataProvider('propertyHookParents')] + public function testCapturePreservesSubclassPropertyHooks(string $parent, bool $nonFinite): void + { + if (PHP_VERSION_ID < 80400) { + self::markTestSkipped('Property hooks require PHP 8.4.'); + } + $object = eval(sprintf(<<<'PHP' + return new class extends %s { + public string $value = 'private fixture' { + get => $this->value === '' ? '' : 'filtered'; + } + public string $virtual { + get => 'visible'; + } + public float $finite = 2.5; + }; + PHP, $parent)); + $expected = ['value' => 'filtered', 'virtual' => 'visible', 'finite' => 2.5]; + self::assertTrue(json_decode(json_encode($object), true, 512, JSON_THROW_ON_ERROR) === $expected); + $httpClient = new MockedHttpClient('unused.invalid'); + $client = new Client('test-key', ['batch_size' => 10, 'flush_interval_seconds' => 3600], $httpClient); + try { + self::assertTrue($client->capture([ + 'distinctId' => 'user', 'event' => 'hooked-subclass', + 'properties' => ['object' => $object, 'number' => $nonFinite ? NAN : 2.5], + ])); + self::assertTrue($client->capture([ + 'distinctId' => 'user', 'event' => 'neighbor', 'properties' => ['finite' => 7], + ])); + if ($nonFinite) { + json_encode(null); + self::assertFalse($client->flush()); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + self::assertNull($httpClient->calls); + } else { + self::assertTrue($client->flush()); + self::assertCount(1, $httpClient->calls); + $payload = json_decode($httpClient->calls[0]['payload'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame(['hooked-subclass', 'neighbor'], array_column($payload['batch'], 'event')); + self::assertTrue( + $payload['batch'][0]['properties']['object'] === $expected, + 'Wire serialization must preserve the filtered public property view.' + ); + self::assertSame(2.5, $payload['batch'][0]['properties']['number']); + self::assertSame(7, $payload['batch'][1]['properties']['finite']); + } + self::assertTrue(((array) $object)['value'] === 'private fixture'); + } finally { + $client->shutdown(); + } + } + + #[DataProvider('nonFiniteValues')] + public function testCaptureSerializesDataOnlyNonFinitePropertiesAsNull(string $consumer, float $number): void + { + $filename = tempnam(sys_get_temp_dir(), 'posthog-nonfinite-'); + $httpClient = new MockedHttpClient('unused.invalid'); + $client = new Client('test-key', [ + 'consumer' => $consumer, 'filename' => $filename, + 'batch_size' => 10, 'flush_interval_seconds' => 3600, + ], $httpClient); + $properties = [ + 'number' => $number, + 'nested' => ['number' => $number], + 'items' => [1, $number, [], 3], + 'sparse' => [2 => $number, 4 => 'kept'], + 'finite' => 2.5, + 'zero' => 0, + 'enabled' => false, + 'nullable' => null, + 'strings' => ['NaN', 'Infinity', '-Infinity'], + ]; + try { + self::assertTrue($client->capture([ + 'distinctId' => 'user', 'event' => 'non-finite', 'properties' => $properties, + ])); + self::assertTrue($client->capture([ + 'distinctId' => 'user', 'event' => 'neighbor', 'properties' => ['finite' => 7], + ])); + self::assertTrue($client->flush()); + if ($consumer === 'file') { + self::assertNull($httpClient->calls); + $events = array_map( + static fn(string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR), + file($filename, FILE_IGNORE_NEW_LINES) + ); + } else { + self::assertCount(1, $httpClient->calls); + self::assertSame('/batch/', $httpClient->calls[0]['path']); + $payload = json_decode($httpClient->calls[0]['payload'], true, 512, JSON_THROW_ON_ERROR); + self::assertSame('test-key', $payload['api_key']); + $events = $payload['batch']; + } + self::assertSame(['non-finite', 'neighbor'], array_column($events, 'event')); + self::assertSame([ + 'number' => null, + 'nested' => ['number' => null], + 'items' => [1, null, [], 3], + 'sparse' => [2 => null, 4 => 'kept'], + 'finite' => 2.5, + 'zero' => 0, + 'enabled' => false, + 'nullable' => null, + 'strings' => ['NaN', 'Infinity', '-Infinity'], + ], array_intersect_key($events[0]['properties'], $properties)); + self::assertSame(7, $events[1]['properties']['finite']); + if (is_nan($number)) { + self::assertNan($properties['number']); + self::assertNan($properties['nested']['number']); + self::assertNan($properties['items'][1]); + self::assertNan($properties['sparse'][2]); + } else { + self::assertSame($number, $properties['number']); + self::assertSame($number, $properties['nested']['number']); + self::assertSame($number, $properties['items'][1]); + self::assertSame($number, $properties['sparse'][2]); + } + } finally { + $client->shutdown(); + unlink($filename); + } + } + + #[DataProvider('nonFiniteValues')] + public function testOpaqueNonFinitePropertiesKeepNativeFailureAndCallerState(string $consumer, float $number): void + { + $filename = tempnam(sys_get_temp_dir(), 'posthog-nonfinite-'); + $httpClient = new MockedHttpClient('unused.invalid'); + $client = new Client('test-key', [ + 'consumer' => $consumer, 'filename' => $filename, + 'batch_size' => 10, 'flush_interval_seconds' => 3600, + ], $httpClient); + $object = (object) ['number' => $number, 'finite' => 4.5]; + $typedObject = new class ($number) { + private float $hidden = INF; + + public function __construct(public readonly float $number) + { + } + }; + $serializable = new class ($object) implements \JsonSerializable { + public function __construct(private object $value) + { + } + + public function jsonSerialize(): mixed + { + return ['visible' => $this->value]; + } + }; + $properties = [ + 'number' => $number, + 'nested' => ['number' => $number, 'object' => $object], + 'items' => [1, $number, $object, 3], + 'finite' => 2.5, + 'zero' => 0, + 'enabled' => false, + 'nullable' => null, + 'strings' => ['NaN', 'Infinity', '-Infinity'], + 'typed' => $typedObject, + 'serializable' => $serializable, + ]; + try { + self::assertFalse(json_encode($properties)); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + json_encode(null); + self::assertTrue($client->capture([ + 'distinctId' => 'user', 'event' => 'non-finite', 'properties' => $properties, + ])); + if ($consumer === 'file') { + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + } + self::assertTrue($client->capture([ + 'distinctId' => 'user', 'event' => 'neighbor', 'properties' => ['finite' => 7], + ])); + if ($consumer === 'file') { + self::assertTrue($client->flush()); + $lines = file($filename, FILE_IGNORE_NEW_LINES); + self::assertCount(2, $lines); + self::assertSame('', $lines[0]); + $neighbor = json_decode($lines[1], true, 512, JSON_THROW_ON_ERROR); + self::assertSame('neighbor', $neighbor['event']); + self::assertSame(7, $neighbor['properties']['finite']); + } else { + json_encode(null); + self::assertFalse($client->flush()); + self::assertSame(JSON_ERROR_INF_OR_NAN, json_last_error()); + } + self::assertNull($httpClient->calls); + self::assertSame($object, $properties['nested']['object']); + self::assertSame($object, $properties['items'][2]); + if (is_nan($number)) { + self::assertNan($properties['number']); + self::assertNan($properties['nested']['number']); + self::assertNan($properties['items'][1]); + self::assertNan($object->number); + self::assertNan($typedObject->number); + } else { + self::assertSame($number, $properties['number']); + self::assertSame($number, $properties['nested']['number']); + self::assertSame($number, $properties['items'][1]); + self::assertSame($number, $object->number); + self::assertSame($number, $typedObject->number); + } + } finally { + $client->shutdown(); + unlink($filename); + } + } +} diff --git a/test/PublicApiSnapshotTest.php b/test/PublicApiSnapshotTest.php new file mode 100644 index 00000000..7a1fc478 --- /dev/null +++ b/test/PublicApiSnapshotTest.php @@ -0,0 +1,26 @@ +invoke(null, __DIR__ . '/../lib', 'PostHog\\'); + + self::assertArrayHasKey(Client::class, $api['symbols']); + self::assertArrayHasKey(Consumer::class, $api['symbols']); + self::assertArrayNotHasKey(JsonEncoder::class, $api['symbols']); + self::assertArrayNotHasKey('PostHog\\JsonEncoder', $api['symbols']); + } +} diff --git a/test/fixtures/json-encoder-data-only.php b/test/fixtures/json-encoder-data-only.php new file mode 100644 index 00000000..48d25faa --- /dev/null +++ b/test/fixtures/json-encoder-data-only.php @@ -0,0 +1,26 @@ + array_fill(0, 4000, ['number' => $number])] + : ['number' => $number, 'text' => str_repeat("\0", 128 * 1024)]; +$batch = [['event' => 'eligible-memory', 'properties' => $properties]]; +if ($argv[3] === 'native') { + $encoded = json_encode(['batch' => $batch, 'api_key' => 'test-key']); +} else { + $consumer = new PostHog\Test\QueueConsumerTestConsumer([]); + $encoded = $consumer->encodedBatchPayload($batch); + if ($argv[2] === 'dense') { + if (!is_nan($properties['items'][0]['number']) || !is_nan($properties['items'][3999]['number'])) { + exit(2); + } + } elseif (!is_nan($properties['number'])) { + exit(2); + } +} +if ($encoded === false) { + exit(1); +} +echo json_encode(['bytes' => strlen($encoded), 'sha256' => hash('sha256', $encoded)]); diff --git a/test/fixtures/json-encoder-large-string.php b/test/fixtures/json-encoder-large-string.php new file mode 100644 index 00000000..587e3cce --- /dev/null +++ b/test/fixtures/json-encoder-large-string.php @@ -0,0 +1,19 @@ + 'large-string', 'properties' => ['value' => str_repeat('A', 12 * 1024 * 1024)]]]; +$payload = ['batch' => $batch, 'api_key' => 'test-key']; +$encoded = $argv[2] === 'native' ? json_encode($payload) : PostHog\Internal\JsonEncoder::encode($payload); +if ($encoded === false) { + exit(1); +} +$summary = ['bytes' => strlen($encoded), 'sha256' => hash('sha256', $encoded)]; +unset($encoded); +if ($argv[2] === 'sdk') { + $consumer = new PostHog\Test\QueueConsumerTestConsumer([]); + if ($consumer->encodedBatchPayload($batch) !== false || json_last_error() !== JSON_ERROR_NONE) { + exit(2); + } +} +echo json_encode($summary); diff --git a/test/fixtures/json-encoder-retained-capacity.php b/test/fixtures/json-encoder-retained-capacity.php new file mode 100644 index 00000000..e54ea689 --- /dev/null +++ b/test/fixtures/json-encoder-retained-capacity.php @@ -0,0 +1,41 @@ + 0; --$i) { + unset($items[$i]); + } +} +$number = $shape === 'siblings' ? INF : NAN; +$items[$index] = $mode === 'native-null' ? null : $number; +$input = $shape === 'siblings' ? ['items' => array_fill(0, 16, $items)] : $items; +$encoded = $mode === 'sdk' ? PostHog\Internal\JsonEncoder::encode($input) : json_encode($input); +if ($mode === 'native-error') { + if ($encoded !== false || json_last_error() !== JSON_ERROR_INF_OR_NAN) { + exit(1); + } + echo json_encode(['error' => JSON_ERROR_INF_OR_NAN]); + exit(0); +} +if ($encoded === false) { + exit(1); +} +if ($mode === 'sdk') { + if (count($items) !== 1 || array_key_first($items) !== $index) { + exit(2); + } + if (is_nan($number) ? !is_nan($items[$index]) : $items[$index] !== $number) { + exit(2); + } +} +echo json_encode(['bytes' => strlen($encoded), 'sha256' => hash('sha256', $encoded)]); diff --git a/test/fixtures/json-encoder-scale.php b/test/fixtures/json-encoder-scale.php new file mode 100644 index 00000000..5687793c --- /dev/null +++ b/test/fixtures/json-encoder-scale.php @@ -0,0 +1,32 @@ + 'scale', 'properties' => ['items' => $items]]]; +if ($argv[3] === 'native') { + $encoded = json_encode(['batch' => $batch, 'api_key' => 'test-key']); +} else { + $encoded = PostHog\Internal\JsonEncoder::encode(['batch' => $batch, 'api_key' => 'test-key']); + $consumer = new PostHog\Test\QueueConsumerTestConsumer([]); + $payload = $consumer->encodedBatchPayload($batch); + $expectedPayload = $argv[2] === 'siblings' ? false : $encoded; + if ($payload !== $expectedPayload || json_last_error() !== JSON_ERROR_NONE) { + exit(3); + } +} +if ($encoded === false) { + exit(1); +} +if ($argv[2] === 'references' && ($numbers[0] !== 1 || $numbers[59999] !== 1)) { + exit(2); +} +echo json_encode(['bytes' => strlen($encoded), 'sha256' => hash('sha256', $encoded)]); diff --git a/test/fixtures/json-encoder-siblings.php b/test/fixtures/json-encoder-siblings.php new file mode 100644 index 00000000..d08f1975 --- /dev/null +++ b/test/fixtures/json-encoder-siblings.php @@ -0,0 +1,93 @@ + $i % 10]; + } + break; +} +$properties = ['items' => $items]; +if ($includeNonFinite) { + $properties['numbers'] = [NAN, INF, -INF]; + $properties['finite'] = 2.5; + $properties['literal'] = 'NaN'; +} +if ($nativeType !== '') { + $native = match ($nativeType) { + 'DateTime' => new DateTime('2024-01-01T00:00:00Z'), + 'DateTimeImmutable' => new DateTimeImmutable('2024-01-01T00:00:00Z'), + 'DateTimeZone' => new DateTimeZone('UTC'), + 'ArrayObject', 'ArrayObject-properties' => new ArrayObject(['stored' => 7]), + 'ArrayIterator', 'ArrayIterator-properties' => new ArrayIterator(['stored' => 7]), + }; + if (str_ends_with($nativeType, '-properties')) { + $native->setFlags(ArrayObject::STD_PROP_LIST); + $native->visible = 7; + } + $properties['native'] = $native; +} +$batch = [['event' => 'memory', 'distinct_id' => 'u', 'properties' => $properties]]; +echo "prepared\n"; +if ($mode === 'native') { + $encoded = json_encode(['batch' => $batch, 'api_key' => 'test-key']); +} else { + $consumer = new PostHog\Test\QueueConsumerTestConsumer([]); + $encoded = $consumer->encodedBatchPayload($batch); +} +$error = json_last_error(); +if ($includeNonFinite) { + if ($encoded !== false || $error !== JSON_ERROR_INF_OR_NAN) { + exit(1); + } +} elseif ($encoded === false) { + exit(1); +} +if ($includeNonFinite) { + if ( + !is_nan($batch[0]['properties']['numbers'][0]) + || $batch[0]['properties']['numbers'][1] !== INF + || $batch[0]['properties']['numbers'][2] !== -INF + ) { + exit(2); + } +} +echo json_encode($includeNonFinite ? ['error' => $error] : [ + 'bytes' => strlen($encoded), + 'sha256' => hash('sha256', $encoded), +], JSON_THROW_ON_ERROR), "\n";