···11+<?php
22+33+declare(strict_types=1);
44+55+namespace SocialDept\Signal\Binary;
66+77+use RuntimeException;
88+99+/**
1010+ * Binary data reader with position tracking.
1111+ *
1212+ * Provides stream-like interface for reading from binary strings.
1313+ */
1414+class Reader
1515+{
1616+ private int $position = 0;
1717+1818+ public function __construct(
1919+ private readonly string $data,
2020+ ) {}
2121+2222+ /**
2323+ * Get current position in the data.
2424+ */
2525+ public function getPosition(): int
2626+ {
2727+ return $this->position;
2828+ }
2929+3030+ /**
3131+ * Get total length of data.
3232+ */
3333+ public function getLength(): int
3434+ {
3535+ return strlen($this->data);
3636+ }
3737+3838+ /**
3939+ * Check if there's more data to read.
4040+ */
4141+ public function hasMore(): bool
4242+ {
4343+ return $this->position < strlen($this->data);
4444+ }
4545+4646+ /**
4747+ * Get remaining bytes count.
4848+ */
4949+ public function remaining(): int
5050+ {
5151+ return strlen($this->data) - $this->position;
5252+ }
5353+5454+ /**
5555+ * Peek at the next byte without advancing position.
5656+ *
5757+ * @throws RuntimeException If no more data available
5858+ */
5959+ public function peek(): int
6060+ {
6161+ if (!$this->hasMore()) {
6262+ throw new RuntimeException('Unexpected end of data');
6363+ }
6464+6565+ return ord($this->data[$this->position]);
6666+ }
6767+6868+ /**
6969+ * Read a single byte and advance position.
7070+ *
7171+ * @throws RuntimeException If no more data available
7272+ */
7373+ public function readByte(): int
7474+ {
7575+ $byte = $this->peek();
7676+ $this->position++;
7777+7878+ return $byte;
7979+ }
8080+8181+ /**
8282+ * Read exactly N bytes and advance position.
8383+ *
8484+ * @throws RuntimeException If not enough data available
8585+ */
8686+ public function readBytes(int $length): string
8787+ {
8888+ if ($this->remaining() < $length) {
8989+ throw new RuntimeException("Cannot read {$length} bytes, only {$this->remaining()} remaining");
9090+ }
9191+9292+ $bytes = substr($this->data, $this->position, $length);
9393+ $this->position += $length;
9494+9595+ return $bytes;
9696+ }
9797+9898+ /**
9999+ * Read a varint (variable-length integer).
100100+ *
101101+ * @throws RuntimeException If varint is malformed
102102+ */
103103+ public function readVarint(): int
104104+ {
105105+ return Varint::decode($this->data, $this->position);
106106+ }
107107+108108+ /**
109109+ * Get all remaining data without advancing position.
110110+ */
111111+ public function peekRemaining(): string
112112+ {
113113+ return substr($this->data, $this->position);
114114+ }
115115+116116+ /**
117117+ * Read all remaining data and advance position to end.
118118+ */
119119+ public function readRemaining(): string
120120+ {
121121+ $remaining = $this->peekRemaining();
122122+ $this->position = strlen($this->data);
123123+124124+ return $remaining;
125125+ }
126126+127127+ /**
128128+ * Skip N bytes forward.
129129+ *
130130+ * @throws RuntimeException If trying to skip past end
131131+ */
132132+ public function skip(int $bytes): void
133133+ {
134134+ if ($this->remaining() < $bytes) {
135135+ throw new RuntimeException("Cannot skip {$bytes} bytes, only {$this->remaining()} remaining");
136136+ }
137137+138138+ $this->position += $bytes;
139139+ }
140140+}
+68
src/Binary/Varint.php
···11+<?php
22+33+declare(strict_types=1);
44+55+namespace SocialDept\Signal\Binary;
66+77+use RuntimeException;
88+99+/**
1010+ * Varint (Variable-Length Integer) decoder for unsigned integers.
1111+ *
1212+ * Used in CAR format to encode block lengths. Implements the same
1313+ * varint encoding used in Protocol Buffers and other binary formats.
1414+ */
1515+class Varint
1616+{
1717+ /**
1818+ * Decode a varint from the beginning of the data.
1919+ *
2020+ * @param string $data Binary data containing varint
2121+ * @param int $offset Starting position (will be updated to position after varint)
2222+ * @return int Decoded unsigned integer
2323+ * @throws RuntimeException If varint is malformed or data ends unexpectedly
2424+ */
2525+ public static function decode(string $data, int &$offset = 0): int
2626+ {
2727+ $result = 0;
2828+ $shift = 0;
2929+ $length = strlen($data);
3030+3131+ while ($offset < $length) {
3232+ $byte = ord($data[$offset]);
3333+ $offset++;
3434+3535+ // Take lower 7 bits and shift into result
3636+ $result |= ($byte & 0x7F) << $shift;
3737+3838+ // If MSB is 0, we're done
3939+ if (($byte & 0x80) === 0) {
4040+ return $result;
4141+ }
4242+4343+ $shift += 7;
4444+4545+ // Prevent overflow (64-bit max)
4646+ if ($shift > 63) {
4747+ throw new RuntimeException('Varint too long (max 64 bits)');
4848+ }
4949+ }
5050+5151+ throw new RuntimeException('Unexpected end of varint data');
5252+ }
5353+5454+ /**
5555+ * Read a varint and return both the value and remaining data.
5656+ *
5757+ * @param string $data Binary data starting with varint
5858+ * @return array{0: int, 1: string} [decoded value, remaining data]
5959+ */
6060+ public static function decodeFirst(string $data): array
6161+ {
6262+ $offset = 0;
6363+ $value = self::decode($data, $offset);
6464+ $remainder = substr($data, $offset);
6565+6666+ return [$value, $remainder];
6767+ }
6868+}
+75
tests/Unit/VarintTest.php
···11+<?php
22+33+declare(strict_types=1);
44+55+namespace SocialDept\Signal\Tests\Unit;
66+77+use PHPUnit\Framework\TestCase;
88+use RuntimeException;
99+use SocialDept\Signal\Binary\Varint;
1010+1111+class VarintTest extends TestCase
1212+{
1313+ public function test_decode_single_byte_values(): void
1414+ {
1515+ $this->assertSame(0, Varint::decode("\x00"));
1616+ $this->assertSame(1, Varint::decode("\x01"));
1717+ $this->assertSame(127, Varint::decode("\x7F"));
1818+ }
1919+2020+ public function test_decode_multi_byte_values(): void
2121+ {
2222+ // 128 = 0x80 0x01
2323+ $this->assertSame(128, Varint::decode("\x80\x01"));
2424+2525+ // 300 = 0xAC 0x02
2626+ $this->assertSame(300, Varint::decode("\xAC\x02"));
2727+2828+ // 16384 = 0x80 0x80 0x01
2929+ $this->assertSame(16384, Varint::decode("\x80\x80\x01"));
3030+ }
3131+3232+ public function test_decode_with_offset(): void
3333+ {
3434+ $data = "\x00\x01\x7F\x80\x01";
3535+ $offset = 0;
3636+3737+ $this->assertSame(0, Varint::decode($data, $offset));
3838+ $this->assertSame(1, $offset);
3939+4040+ $this->assertSame(1, Varint::decode($data, $offset));
4141+ $this->assertSame(2, $offset);
4242+4343+ $this->assertSame(127, Varint::decode($data, $offset));
4444+ $this->assertSame(3, $offset);
4545+4646+ $this->assertSame(128, Varint::decode($data, $offset));
4747+ $this->assertSame(5, $offset);
4848+ }
4949+5050+ public function test_decode_first_returns_value_and_remainder(): void
5151+ {
5252+ [$value, $remainder] = Varint::decodeFirst("\x7F\x01\x02\x03");
5353+5454+ $this->assertSame(127, $value);
5555+ $this->assertSame("\x01\x02\x03", $remainder);
5656+ }
5757+5858+ public function test_decode_throws_on_unexpected_end(): void
5959+ {
6060+ $this->expectException(RuntimeException::class);
6161+ $this->expectExceptionMessage('Unexpected end of varint data');
6262+6363+ Varint::decode("\x80");
6464+ }
6565+6666+ public function test_decode_throws_on_too_long_varint(): void
6767+ {
6868+ $this->expectException(RuntimeException::class);
6969+ $this->expectExceptionMessage('Varint too long (max 64 bits)');
7070+7171+ // Create a varint that would be longer than 64 bits (10 bytes with continuation bits)
7272+ $tooLong = str_repeat("\xFF", 10);
7373+ Varint::decode($tooLong);
7474+ }
7575+}