From faf9d3f5f6070eafe17db2299384ea6f195407b9 Mon Sep 17 00:00:00 2001 From: Ralf Lang Date: Thu, 11 Jun 2026 23:09:53 +0200 Subject: [PATCH] feat(serializer): pure-PHP session-data serializer NativePhpSessionSerializer wraps session_encode and session_decode. Both functions only operate on the active PHP session via the session module. Modern PSR-15 routes that own their session lifecycle through SessionLifecycle and middleware never call session_start, so the native serializer cannot read or write rows for them. PhpTextSessionSerializer implements PHP's default php session.serialize_handler format directly. Layout is a flat concatenation of | records. Reader uses the re-serialize-and-measure trick to advance past each record because unserialize does not report bytes consumed. DefaultSessionSerializer dispatches to PhpTextSessionSerializer for session.serialize_handler=php and to PhpSessionSerializer for php_serialize. Other handlers (igbinary, custom) raise a clear error at construction. The handler is captured at construction time and frozen, matching how PHP's session module reads the ini once on open. Both formats produce the exact byte sequence that PHP's session module produces on the same payload, so legacy callers that go through session_start continue to read and write the same on-disk rows. Modern and legacy stacks share storage. --- src/DefaultSessionSerializer.php | 92 +++++++++++ src/PhpTextSessionSerializer.php | 134 +++++++++++++++ test/unit/DefaultSessionSerializerTest.php | 80 +++++++++ test/unit/PhpTextSessionSerializerTest.php | 181 +++++++++++++++++++++ 4 files changed, 487 insertions(+) create mode 100644 src/DefaultSessionSerializer.php create mode 100644 src/PhpTextSessionSerializer.php create mode 100644 test/unit/DefaultSessionSerializerTest.php create mode 100644 test/unit/PhpTextSessionSerializerTest.php diff --git a/src/DefaultSessionSerializer.php b/src/DefaultSessionSerializer.php new file mode 100644 index 0000000..454d715 --- /dev/null +++ b/src/DefaultSessionSerializer.php @@ -0,0 +1,92 @@ + + */ + +namespace Horde\SessionHandler; + +use Horde\SessionHandler\Exception\SerializationException; + +/** + * Dispatching session serializer that picks an inner implementation + * matching PHP's `session.serialize_handler` ini setting. + * + * Two formats are supported, matching what stock PHP and typical + * distributions ship: + * + * - `php` (the default): see {@see PhpTextSessionSerializer}. + * - `php_serialize`: see {@see PhpSessionSerializer}. + * + * Other handlers (`igbinary`, `wddx`, custom registrations) are + * rejected at construction time. If the modern stack ever needs them, + * write a serializer for the format and extend the dispatcher; until + * then, fail loudly so operators see a clear error rather than + * silently-corrupted session data. + * + * The chosen handler is captured at construction and frozen; changes + * to the ini at runtime do not retroactively switch the serializer. + * That matches PHP's own behaviour: the session module reads the + * setting once when the session opens. + */ +final class DefaultSessionSerializer implements SessionSerializer +{ + private SessionSerializer $inner; + + /** + * @param string|null $handler Override the auto-detected handler. + * When null, reads from + * `ini_get('session.serialize_handler')` + * with a fallback to `php` if that + * returns empty (CLI sessions disabled, + * etc.). + */ + public function __construct(?string $handler = null) + { + $resolved = $handler ?? $this->detectHandler(); + $this->inner = match ($resolved) { + 'php' => new PhpTextSessionSerializer(), + 'php_serialize' => new PhpSessionSerializer(), + default => throw new SerializationException(sprintf( + 'Unsupported session.serialize_handler "%s". ' + . 'Supported handlers are "php" and "php_serialize". ' + . 'For other formats (igbinary, custom) a dedicated ' + . 'serializer must be written.', + $resolved, + )), + }; + } + + public function serialize(Session $session): SerializedSessionPayload + { + return $this->inner->serialize($session); + } + + /** @return array */ + public function deserialize(SerializedSessionPayload $payload): array + { + return $this->inner->deserialize($payload); + } + + /** Internal: which inner serializer is in use. */ + public function inner(): SessionSerializer + { + return $this->inner; + } + + private function detectHandler(): string + { + $value = ini_get('session.serialize_handler'); + if (is_string($value) && $value !== '') { + return $value; + } + return 'php'; + } +} diff --git a/src/PhpTextSessionSerializer.php b/src/PhpTextSessionSerializer.php new file mode 100644 index 0000000..10c6f28 --- /dev/null +++ b/src/PhpTextSessionSerializer.php @@ -0,0 +1,134 @@ + + */ + +namespace Horde\SessionHandler; + +use Horde\SessionHandler\Exception\SerializationException; + +/** + * Serializer for PHP's default `php` session-data format. + * + * Layout: a flat concatenation of `|` records, + * one per top-level session key. No header, no separator, no footer. + * The serialised value is self-delimiting; the parser advances by the + * exact byte length of each `serialize()` output. + * + * This is the default of PHP's `session.serialize_handler`. Reading + * and writing this format does NOT require an active PHP session + * (unlike `session_encode()` / `session_decode()` which only operate + * on `$_SESSION` while the session module is open). Modern PSR-15 + * middleware and CLI consumers use this serializer to interoperate + * with sessions written by the legacy stack. + * + * Limitations match the platform: keys cannot contain `|`, `!`, or + * `\0`. Keys with those bytes are rejected during serialize and + * serialised payloads with those bytes embedded structurally cause + * deserialise failures the same way PHP's native handler would + * silently truncate at the offending byte. + */ +final class PhpTextSessionSerializer implements SessionSerializer +{ + /** + * Bytes PHP forbids in session keys. The session module aborts the + * write with a notice; this serializer rejects up front. + */ + private const FORBIDDEN_KEY_BYTES = ['|', '!', "\0"]; + + public function serialize(Session $session): SerializedSessionPayload + { + $out = ''; + foreach ($session->toPayload() as $key => $value) { + $stringKey = (string) $key; + $this->guardKey($stringKey); + $out .= $stringKey . '|' . serialize($value); + } + return new SerializedSessionPayload($out); + } + + /** @return array */ + public function deserialize(SerializedSessionPayload $payload): array + { + if ($payload->isEmpty()) { + return []; + } + + $data = $payload->getData(); + $length = strlen($data); + $pos = 0; + $result = []; + + while ($pos < $length) { + $pipe = strpos($data, '|', $pos); + if ($pipe === false) { + throw new SerializationException(sprintf( + 'Malformed session payload: expected key|value record at offset %d', + $pos, + )); + } + + $key = substr($data, $pos, $pipe - $pos); + $valueStart = $pipe + 1; + if ($valueStart >= $length) { + throw new SerializationException(sprintf( + 'Malformed session payload: missing value for key "%s" at offset %d', + $key, + $pipe, + )); + } + + // unserialize() does not report how many bytes it consumed. + // Re-serialise the result and use its byte length to advance + // the cursor. serialize() is canonical: an unserialise/ + // reserialise round-trip yields the same bytes. + $value = @unserialize(substr($data, $valueStart), ['allowed_classes' => true]); + $remaining = substr($data, $valueStart); + + if ($value === false && !$this->looksLikeSerializedFalse($remaining)) { + throw new SerializationException(sprintf( + 'Malformed session payload: cannot unserialise value for key "%s" at offset %d', + $key, + $valueStart, + )); + } + + $consumed = strlen(serialize($value)); + $result[$key] = $value; + $pos = $valueStart + $consumed; + } + + return $result; + } + + private function guardKey(string $key): void + { + foreach (self::FORBIDDEN_KEY_BYTES as $byte) { + if (str_contains($key, $byte)) { + throw new SerializationException(sprintf( + 'Invalid session key "%s": contains byte 0x%s which the PHP ' + . 'session-data format does not permit.', + $key, + bin2hex($byte), + )); + } + } + } + + /** + * Distinguish a genuine `b:0;` (serialised false) from an + * unserialise() failure that also returns false. + */ + private function looksLikeSerializedFalse(string $bytes): bool + { + return str_starts_with($bytes, 'b:0;'); + } +} diff --git a/test/unit/DefaultSessionSerializerTest.php b/test/unit/DefaultSessionSerializerTest.php new file mode 100644 index 0000000..b362aeb --- /dev/null +++ b/test/unit/DefaultSessionSerializerTest.php @@ -0,0 +1,80 @@ +inner()); + } + + #[Test] + public function testPickPhpSerializeForPhpSerializeHandler(): void + { + $serializer = new DefaultSessionSerializer('php_serialize'); + self::assertInstanceOf(PhpSessionSerializer::class, $serializer->inner()); + } + + #[Test] + public function testRejectsUnsupportedHandler(): void + { + $this->expectException(SerializationException::class); + new DefaultSessionSerializer('igbinary'); + } + + #[Test] + public function testRejectsCustomHandler(): void + { + $this->expectException(SerializationException::class); + new DefaultSessionSerializer('user'); + } + + #[Test] + public function testRoundTripDelegatesToInner(): void + { + $serializer = new DefaultSessionSerializer('php'); + $session = new DefaultSession(new SessionId('rt'), ['k' => 'v']); + + $payload = $serializer->serialize($session); + $result = $serializer->deserialize($payload); + + self::assertSame(['k' => 'v'], $result); + } + + #[Test] + public function testNullHandlerFallsBackToIniValueOrPhp(): void + { + // Null means auto-detect. The detected value is whatever the + // running PHP says; under both common configurations (php or + // php_serialize) the construction succeeds. Just assert that + // construction does NOT throw and produces a known inner type. + $serializer = new DefaultSessionSerializer(); + self::assertTrue( + $serializer->inner() instanceof PhpTextSessionSerializer + || $serializer->inner() instanceof PhpSessionSerializer, + ); + } +} diff --git a/test/unit/PhpTextSessionSerializerTest.php b/test/unit/PhpTextSessionSerializerTest.php new file mode 100644 index 0000000..c89c7c3 --- /dev/null +++ b/test/unit/PhpTextSessionSerializerTest.php @@ -0,0 +1,181 @@ +serializer = new PhpTextSessionSerializer(); + } + + #[Test] + public function testRoundTripPreservesScalarPayload(): void + { + $session = new DefaultSession(new SessionId('rt-scalars'), [ + 'user' => 'alice', + 'count' => 42, + 'flag' => true, + 'rate' => 1.5, + 'absent' => null, + ]); + + $payload = $this->serializer->serialize($session); + $result = $this->serializer->deserialize($payload); + + self::assertSame( + ['user' => 'alice', 'count' => 42, 'flag' => true, 'rate' => 1.5, 'absent' => null], + $result, + ); + } + + #[Test] + public function testRoundTripPreservesNestedArrays(): void + { + $session = new DefaultSession(new SessionId('rt-nested'), [ + 'horde' => [ + 'auth/userId' => 'alice', + 'auth_app/imp' => ['user' => 'a', 'pass' => 'b'], + ], + '_b' => 1700000000, + ]); + + $payload = $this->serializer->serialize($session); + $result = $this->serializer->deserialize($payload); + + self::assertSame( + [ + 'horde' => [ + 'auth/userId' => 'alice', + 'auth_app/imp' => ['user' => 'a', 'pass' => 'b'], + ], + '_b' => 1700000000, + ], + $result, + ); + } + + #[Test] + public function testEmptyPayloadDeserializesToEmptyArray(): void + { + $result = $this->serializer->deserialize(new SerializedSessionPayload('')); + self::assertSame([], $result); + } + + #[Test] + public function testEmptySessionSerializesToEmptyPayload(): void + { + $session = new DefaultSession(new SessionId('empty'), []); + $payload = $this->serializer->serialize($session); + self::assertTrue($payload->isEmpty()); + } + + #[Test] + public function testWireFormatMatchesPhpDefault(): void + { + // Spec lock: the text format is "|" + // concatenated. Asserting the byte sequence guards against + // accidental layout drift in either direction. + $session = new DefaultSession(new SessionId('wire'), [ + 'a' => 'x', + 'n' => 5, + ]); + $payload = $this->serializer->serialize($session); + self::assertSame('a|s:1:"x";n|i:5;', $payload->getData()); + } + + #[Test] + public function testReadsPayloadProducedByPhpSessionEncode(): void + { + // Output of `session_encode()` for ['user' => 'bob', 'n' => 7] + // under session.serialize_handler = php. + $payload = new SerializedSessionPayload('user|s:3:"bob";n|i:7;'); + $result = $this->serializer->deserialize($payload); + self::assertSame(['user' => 'bob', 'n' => 7], $result); + } + + #[Test] + public function testRejectsKeyContainingPipe(): void + { + $session = new DefaultSession(new SessionId('bad-key'), ['a|b' => 'x']); + $this->expectException(SerializationException::class); + $this->serializer->serialize($session); + } + + #[Test] + public function testRejectsKeyContainingNullByte(): void + { + $session = new DefaultSession(new SessionId('bad-key2'), ["a\0b" => 'x']); + $this->expectException(SerializationException::class); + $this->serializer->serialize($session); + } + + #[Test] + public function testRejectsKeyContainingExclamation(): void + { + $session = new DefaultSession(new SessionId('bad-key3'), ['a!b' => 'x']); + $this->expectException(SerializationException::class); + $this->serializer->serialize($session); + } + + #[Test] + public function testRejectsMalformedPayloadWithoutPipe(): void + { + $payload = new SerializedSessionPayload('no-pipe-here'); + $this->expectException(SerializationException::class); + $this->serializer->deserialize($payload); + } + + #[Test] + public function testRejectsMalformedSerializedValue(): void + { + $payload = new SerializedSessionPayload('user|not-a-serialize-payload'); + $this->expectException(SerializationException::class); + $this->serializer->deserialize($payload); + } + + #[Test] + public function testPreservesGenuineSerializedFalse(): void + { + $session = new DefaultSession(new SessionId('false-val'), ['flag' => false]); + $payload = $this->serializer->serialize($session); + $result = $this->serializer->deserialize($payload); + self::assertSame(['flag' => false], $result); + } + + #[Test] + public function testRoundTripBinaryPayload(): void + { + // Horde_Pack-style binary payloads get stored as the raw bytes + // of a string value. Ensures the serializer doesn't choke on + // null bytes inside string values (only inside keys is forbidden). + $binary = "\x01\x02\x03\xff\x00data"; + $session = new DefaultSession(new SessionId('binary'), ['blob' => $binary]); + + $payload = $this->serializer->serialize($session); + $result = $this->serializer->deserialize($payload); + + self::assertSame(['blob' => $binary], $result); + } +}