diff --git a/.changeset/warm-apples-invite.md b/.changeset/warm-apples-invite.md new file mode 100644 index 0000000..f83dcea --- /dev/null +++ b/.changeset/warm-apples-invite.md @@ -0,0 +1,5 @@ +--- +"posthog-php": patch +--- + +Preserve escaped characters in gzip-compressed fork-curl event payloads. diff --git a/.github/workflows/php.yml b/.github/workflows/php.yml index dbe06f5..520003e 100644 --- a/.github/workflows/php.yml +++ b/.github/workflows/php.yml @@ -88,6 +88,7 @@ jobs: uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: php-version: ${{ matrix.php-version }} + extensions: zlib tools: composer, phpunit - name: Install Dependencies @@ -111,7 +112,7 @@ jobs: uses: shivammathur/setup-php@f3e473d116dcccaddc5834248c87452386958240 # v2.37.2 with: php-version: 8.4 - extensions: xdebug + extensions: xdebug, zlib tools: composer, phpunit - name: Install Dependencies diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b563048..e00baee 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,7 +4,7 @@ Thanks for your interest in improving the PostHog PHP SDK. ## Development setup -1. Install [PHP](https://www.php.net/manual/en/install.php) and [Composer](https://getcomposer.org/download/). +1. Install [PHP](https://www.php.net/manual/en/install.php) and [Composer](https://getcomposer.org/download/). Enable PHP's `zlib` extension for the gzip regression tests. This is a development-only dependency, not an SDK runtime requirement. 2. Install dependencies using the same command CI uses: ```bash diff --git a/composer.json b/composer.json index 7e39d39..2de8957 100644 --- a/composer.json +++ b/composer.json @@ -22,7 +22,8 @@ }, "require-dev": { "phpunit/phpunit": "^11.0", - "squizlabs/php_codesniffer": "^4.0" + "squizlabs/php_codesniffer": "^4.0", + "ext-zlib": "*" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index d63ff9c..636e297 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "73333ea464ae5ca7fc6f26c2bc87f8a1", + "content-hash": "0d12cb0c9197e8ab24cd4fe6aebf0311", "packages": [ { "name": "psr/clock", @@ -2258,6 +2258,8 @@ "ext-json": "*", "php": ">=8.2" }, - "platform-dev": {}, + "platform-dev": { + "ext-zlib": "*" + }, "plugin-api-version": "2.9.0" } diff --git a/lib/Consumer/ForkCurl.php b/lib/Consumer/ForkCurl.php index 60349dc..9d4f8e3 100644 --- a/lib/Consumer/ForkCurl.php +++ b/lib/Consumer/ForkCurl.php @@ -70,7 +70,7 @@ public function flushBatch($messages) if ($this->compress_request) { // Compress request to file $tmpfname = tempnam("/tmp", "forkcurl_"); - $cmd2 = "echo " . $payload . " | gzip > " . $tmpfname; + $cmd2 = "printf '%s' " . $payload . " | gzip > " . $tmpfname; exec($cmd2, $output, $exit); if (0 == $exit) { diff --git a/test/ForkCurlCompressionTest.php b/test/ForkCurlCompressionTest.php new file mode 100644 index 0000000..07724d0 --- /dev/null +++ b/test/ForkCurlCompressionTest.php @@ -0,0 +1,51 @@ + [false], 'gzip' => [true]]; + } + + #[DataProvider('compressionCases')] + public function testEscapedCharactersSurviveDelivery(bool $compressed): void + { + $server = new LocalHttpServer(); + try { + $consumer = new ForkCurl('test-key', [ + 'host' => $server->address(), + 'ssl' => false, + 'debug' => true, + 'compress_request' => $compressed, + ]); + $message = [ + 'event' => 'quoted "event" with an apostrophe\'', + 'distinct_id' => 'test-user', + 'properties' => [ + 'controls' => "line one\nline two\tvalue\rreturn", + 'literal_backslashes' => '\\n \\t C:\\path\\file', + 'format_string' => '%s %b %n', + 'unicode' => 'café 🌍', + ], + ]; + + self::assertTrue($consumer->flushBatch([$message])); + $requests = $server->requests(); + self::assertCount(1, $requests); + self::assertSame('POST /batch/ HTTP/1.1', $requests[0]['requestLine']); + self::assertSame($compressed ? 'gzip' : null, $requests[0]['headers']['content-encoding'] ?? null); + self::assertSame( + ['batch' => [$message], 'api_key' => 'test-key'], + json_decode($requests[0]['body'], true, 512, JSON_THROW_ON_ERROR) + ); + } finally { + $server->stop(); + } + } +} diff --git a/test/LocalHttpServer.php b/test/LocalHttpServer.php new file mode 100644 index 0000000..bebe15c --- /dev/null +++ b/test/LocalHttpServer.php @@ -0,0 +1,68 @@ +requestsFile = tempnam(sys_get_temp_dir(), 'posthog-requests-'); + $this->errorsFile = tempnam(sys_get_temp_dir(), 'posthog-server-'); + $this->process = proc_open( + [PHP_BINARY, __DIR__ . '/fixtures/http-server.php', $this->requestsFile, json_encode($responses)], + [0 => ['pipe', 'r'], 1 => ['pipe', 'w'], 2 => ['file', $this->errorsFile, 'a']], + $pipes + ); + if (!is_resource($this->process)) { + $this->stop(); + throw new RuntimeException('Could not start local HTTP server'); + } + fclose($pipes[0]); + stream_set_timeout($pipes[1], 5); + $this->address = trim(fgets($pipes[1]) ?: ''); + fclose($pipes[1]); + if ($this->address === '') { + $error = file_get_contents($this->errorsFile); + $this->stop(); + throw new RuntimeException('Local HTTP server did not become ready: ' . $error); + } + } + + public function address(): string + { + return $this->address; + } + + public function requests(): array + { + return array_map( + static fn(string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR), + file($this->requestsFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES) + ); + } + + public function stop(): void + { + if (is_resource($this->process)) { + proc_terminate($this->process); + proc_close($this->process); + } + foreach ([$this->requestsFile, $this->errorsFile] as $file) { + if (is_file($file)) { + unlink($file); + } + } + } + + public function __destruct() + { + $this->stop(); + } +} diff --git a/test/fixtures/http-server.php b/test/fixtures/http-server.php new file mode 100644 index 0000000..2575c00 --- /dev/null +++ b/test/fixtures/http-server.php @@ -0,0 +1,57 @@ + $requestLine, + 'headers' => $headers, + 'body' => $body, + ], JSON_THROW_ON_ERROR) . "\n", FILE_APPEND); + + $response = array_shift($responses) ?? []; + $status = $response['status'] ?? 200; + $body = $response['body'] ?? '{"status":1}'; + $headers = ''; + foreach ($response['headers'] ?? [] as $name => $value) { + $headers .= "$name: $value\r\n"; + } + $wire = "HTTP/1.1 $status Response\r\n" . $headers + . 'Content-Length: ' . strlen($body) . "\r\nConnection: close\r\n\r\n" . $body; + while ($wire !== '') { + $written = fwrite($connection, $wire); + if ($written === false || $written === 0) { + break; + } + $wire = substr($wire, $written); + } + fclose($connection); +}