'commit', 2 => 'tree', 3 => 'blob', 4 => 'tag']; /** @var resource|null */ private $fh = null; private string $idx = ''; private int $version = 2; private int $count = 0; private int $namesAt = 0; private int $crcAt = 0; private int $offsetsAt = 0; private int $largeAt = 0; private ?string $rev = null; public readonly int $size; /** * @param string $path .packのパス * @param int $hashLen 20(SHA-1)又は32(SHA-256) * @param bool $withIndex .idxを読む(受け取り中のパックはfalse) */ public function __construct(public readonly string $path, private int $hashLen, bool $withIndex = true) { $fh = @fopen($path, 'rb'); if ($fh === false) throw new GitException("パックを開けません:{$path}"); $this->fh = $fh; $this->size = (int)fstat($fh)['size']; $head = (string)fread($fh, 12); if (strlen($head) < 12 || substr($head, 0, 4) !== 'PACK' || !in_array(unpack('N', $head, 4)[1], [2, 3], true)) throw new GitException("パックではありません:{$path}"); if ($withIndex) $this->loadIndex(substr($path, 0, -5).'.idx'); } public function __destruct() { if ($this->fh) fclose($this->fh); } private function loadIndex(string $file): void { $idx = @file_get_contents($file); if ($idx === false) throw new GitException("インデックスを読めません:{$file}"); $this->idx = $idx; $h = $this->hashLen; if (substr($idx, 0, 4) === "\xfftOc") { $this->version = unpack('N', $idx, 4)[1]; if ($this->version !== 2) throw new GitException("未対応のインデックスのバージョン:{$this->version}"); $this->count = unpack('N', $idx, 8 + 255 * 4)[1]; $this->namesAt = 8 + 256 * 4; $this->crcAt = $this->namesAt + $this->count * $h; $this->offsetsAt = $this->crcAt + $this->count * 4; $this->largeAt = $this->offsetsAt + $this->count * 4; } else { // v1:fanout、[オフセット(4) 名前]の配列 $this->version = 1; $this->count = unpack('N', $idx, 255 * 4)[1]; $this->namesAt = 256 * 4; } if (strlen($idx) < $this->namesAt + $this->count * $h) throw new GitException("壊れたインデックス:{$file}"); } public function count(): int { return $this->count; } /** n番目(名前の順)のID(バイナリ) */ public function idAt(int $n): string { return $this->version === 2 ? substr($this->idx, $this->namesAt + $n * $this->hashLen, $this->hashLen) : substr($this->idx, $this->namesAt + $n * ($this->hashLen + 4) + 4, $this->hashLen); } /** n番目のオフセット */ public function offsetAt(int $n): int { if ($this->version === 1) return unpack('N', $this->idx, $this->namesAt + $n * ($this->hashLen + 4))[1]; $o = unpack('N', $this->idx, $this->offsetsAt + $n * 4)[1]; if ($o & 0x80000000) $o = unpack('J', $this->idx, $this->largeAt + ($o & 0x7fffffff) * 8)[1]; return $o; } public function crcAt(int $n): ?int { return $this->version === 2 ? unpack('N', $this->idx, $this->crcAt + $n * 4)[1] : null; } /** * IDの位置(名前の順の番号) * * @param string $id バイナリのID */ public function position(string $id): ?int { $first = ord($id[0]); $fan = $this->version === 2 ? 8 : 0; $lo = $first === 0 ? 0 : unpack('N', $this->idx, $fan + ($first - 1) * 4)[1]; $hi = unpack('N', $this->idx, $fan + $first * 4)[1] - 1; while ($lo <= $hi) { $mid = ($lo + $hi) >> 1; $c = strcmp($this->idAt($mid), $id); if ($c === 0) return $mid; if ($c < 0) $lo = $mid + 1; else $hi = $mid - 1; } return null; } /** IDのオフセット(無ければnull) */ public function find(string $id): ?int { $n = $this->position($id); return $n === null ? null : $this->offsetAt($n); } /** * 始まりが一致するID(短いIDの解決用) * * @param string $prefix 16進数(偶数でなくても良い) * @return string[] 16進数(最大2件) */ public function findPrefix(string $prefix): array { $out = []; $first = hexdec(substr($prefix . '0', 0, 2)); $fan = $this->version === 2 ? 8 : 0; $lo = $first === 0 ? 0 : unpack('N', $this->idx, $fan + ($first - 1) * 4)[1]; $hi = unpack('N', $this->idx, $fan + (strlen($prefix) >= 2 ? $first : min(255, $first + 15)) * 4)[1]; for ($n = $lo; $n < $hi && count($out) < 2; $n++) { $hex = bin2hex($this->idAt($n)); if (str_starts_with($hex, $prefix)) $out[] = $hex; } return $out; } /** 全部のID(16進数) */ public function ids(): \Generator { for ($n = 0; $n < $this->count; $n++) yield bin2hex($this->idAt($n)); } /** * エントリーのヘッダー * * @return array [種類, 大きさ, データの位置, 元のオフセット(OFS), 元のID(REF、バイナリ)] */ public function header(int $offset): array { fseek($this->fh, $offset); $buf = (string)fread($this->fh, 32 + $this->hashLen); $p = 0; $b = ord($buf[$p++]); $type = ($b >> 4) & 7; $size = $b & 15; $shift = 4; while ($b & 0x80) { $b = ord($buf[$p++]); $size |= ($b & 0x7f) << $shift; $shift += 7; } $baseOffset = null; $baseId = null; if ($type === self::OFS_DELTA) { $b = ord($buf[$p++]); $n = $b & 0x7f; while ($b & 0x80) { $b = ord($buf[$p++]); $n = (($n + 1) << 7) | ($b & 0x7f); } $baseOffset = $offset - $n; if ($baseOffset < 12 || $baseOffset >= $offset) throw new GitException("壊れたパック(OFS_DELTA):{$this->path}"); } else if ($type === self::REF_DELTA) { $baseId = substr($buf, $p, $this->hashLen); $p += $this->hashLen; } else if (!isset(self::TYPES[$type])) { throw new GitException("壊れたパック(種類{$type}、{$offset}):{$this->path}"); } return [$type, $size, $offset + $p, $baseOffset, $baseId]; } /** * zlibのデータを展開する * * @param int $pos データの位置 * @param int $size 展開後の大きさ(分かっていれば読む量の目安) * @param int|null $consumed 圧縮されたデータの長さ */ public function inflate(int $pos, int $size, ?int &$consumed = null): string { fseek($this->fh, $pos); $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); $out = ''; $chunk = min(max(256, $size + 64), 1048576); while (true) { $in = fread($this->fh, $chunk); if ($in === false || $in === '') throw new GitException("壊れたパック(途中で終わる):{$this->path}"); $res = @inflate_add($ctx, $in, ZLIB_SYNC_FLUSH); if ($res === false) throw new GitException("壊れたパック(zlib):{$this->path}"); $out .= $res; if (inflate_get_status($ctx) === ZLIB_STREAM_END) break; $chunk = 65536; } $consumed = inflate_get_read_len($ctx); if (strlen($out) !== $size) throw new GitException("壊れたパック(大きさ):{$this->path}"); return $out; } /** 位置のオフセットに対する次のエントリーの始まり(.revか、無ければ作る) */ public function entryEnd(int $offset): int { $this->loadRev(); // オフセットの順のn番目 $lo = 0; $hi = $this->count - 1; while ($lo <= $hi) { $mid = ($lo + $hi) >> 1; $o = $this->offsetAt(unpack('N', $this->rev, $mid * 4)[1]); if ($o === $offset) return $mid + 1 < $this->count ? $this->offsetAt(unpack('N', $this->rev, ($mid + 1) * 4)[1]) : $this->size - $this->hashLen; if ($o < $offset) $lo = $mid + 1; else $hi = $mid - 1; } throw new GitException("パックにエントリーが有りません({$offset}):{$this->path}"); } /** オフセットのID(バイナリ) */ public function idAtOffset(int $offset): ?string { $this->loadRev(); $lo = 0; $hi = $this->count - 1; while ($lo <= $hi) { $mid = ($lo + $hi) >> 1; $n = unpack('N', $this->rev, $mid * 4)[1]; $o = $this->offsetAt($n); if ($o === $offset) return $this->idAt($n); if ($o < $offset) $lo = $mid + 1; else $hi = $mid - 1; } return null; } /** 逆インデックス(オフセットの順の名前の番号)。.revが有れば使う */ private function loadRev(): void { if ($this->rev !== null) return; $file = substr($this->path, 0, -5).'.rev'; $r = is_file($file) ? (string)file_get_contents($file) : ''; if (strlen($r) >= 12 + $this->count * 4 && substr($r, 0, 4) === 'RIDX' && unpack('N', $r, 4)[1] === 1) { $this->rev = substr($r, 12, $this->count * 4); return; } $offs = []; for ($n = 0; $n < $this->count; $n++) $offs[$n] = $this->offsetAt($n); asort($offs); $this->rev = pack('N*', ...array_keys($offs)); } /** パックの中身の生のバイト列(ヘッダーと圧縮されたデータ) */ public function raw(int $start, int $end): string { fseek($this->fh, $start); $s = (string)fread($this->fh, $end - $start); if (strlen($s) !== $end - $start) throw new GitException("パックを読めません:{$this->path}"); return $s; } }