[種類, 大きさ, データの位置, 元のオフセット, 元のID(bin), crc] */ private array $entries = []; /** @var array オフセット => ID(bin) */ private array $ids = []; /** @var array ID(hex) => オフセット */ private array $byId = []; /** @var array オフセット => 種類 */ private array $types = []; private array $cache = []; private int $cacheBytes = 0; private ?Pack $pack = null; /** 参照しているID(接続の確認用) */ private array $links = []; private bool $thin = false; public function __construct(private Repository $repo) {} /** * パックを受け取る * * @param resource $in 入力(パックの始まりから) * @param int $unpackLimit これより少なければルーズオブジェクトにする * @param callable|null $progress fn(string $what, int $done, int $total) * @return array objects(ID => 種類)、links(参照しているID => true)、pack(パックのパス、ルーズならnull) */ public function receive($in, int $unpackLimit = 100, ?callable $progress = null): array { $this->in = $in; $this->hash = hash_init($this->repo->hashAlgo); $dir = $this->repo->objects->dir().'/pack'; $this->repo->mkdir($dir); $this->tmpPath = $dir.'/tmp_pack_'.bin2hex(random_bytes(6)); $tmp = @fopen($this->tmpPath, 'w+b'); if ($tmp === false) throw new GitException("一時ファイルを作れません:{$this->tmpPath}"); $this->tmp = $tmp; try { $head = $this->read(12); if (substr($head, 0, 4) !== 'PACK' || !in_array(unpack('N', $head, 4)[1], [2, 3], true)) throw new GitException('パックではありません'); $count = unpack('N', $head, 8)[1]; for ($i = 0; $i < $count; $i++) { $this->readEntry(); if ($progress && (($i + 1) % 1000 === 0 || $i + 1 === $count)) $progress('受信', $i + 1, $count); } $sum = hash_final($this->hash, true); $trailer = $this->read($this->repo->hashLen, false); if ($trailer !== $sum) throw new GitException('パックのチェックサムが違います'); fwrite($this->tmp, $trailer); fflush($this->tmp); $this->resolveAll($progress); $objects = []; foreach ($this->ids as $off => $bin) $objects[bin2hex($bin)] = $this->types[$off]; // 中に無い物を参照しているか(相手が持っている物はOK、無い物は接続の確認で) foreach (array_keys($this->links) as $l) if (isset($objects[$l])) unset($this->links[$l]); if ($count < $unpackLimit || $this->thin) { foreach (array_keys($this->entries) as $off) { [$type, $data] = $this->resolve($off); $this->repo->objects->write($type, $data); } $path = null; } else { $path = $this->install($sum); } return ['objects' => $objects, 'links' => $this->links, 'pack' => $path]; } finally { $this->pack = null; if (is_resource($this->tmp)) fclose($this->tmp); @unlink($this->tmpPath); } } /** 入力からnバイト(ハッシュと一時ファイルにも) */ private function read(int $n, bool $hash = true): string { while (strlen($this->buf) < $n) { $s = fread($this->in, max(65536, $n - strlen($this->buf))); if ($s === false || $s === '') { if (feof($this->in)) throw new GitException('パックが途中で終わりました'); continue; } $this->buf .= $s; } $out = substr($this->buf, 0, $n); $this->buf = (string)substr($this->buf, $n); if ($hash) { hash_update($this->hash, $out); fwrite($this->tmp, $out); $this->pos += $n; } return $out; } private function readEntry(): void { $offset = $this->pos; $crc = hash_init('crc32b'); $b = ord($h = $this->read(1)); $raw = $h; $type = ($b >> 4) & 7; $size = $b & 15; $shift = 4; while ($b & 0x80) { $b = ord($h = $this->read(1)); $raw .= $h; $size |= ($b & 0x7f) << $shift; $shift += 7; } $baseOff = null; $baseId = null; if ($type === Pack::OFS_DELTA) { $b = ord($h = $this->read(1)); $raw .= $h; $n = $b & 0x7f; while ($b & 0x80) { $b = ord($h = $this->read(1)); $raw .= $h; $n = (($n + 1) << 7) | ($b & 0x7f); } $baseOff = $offset - $n; if (!isset($this->entries[$baseOff])) throw new GitException('壊れたパック(OFS_DELTAの元)'); } else if ($type === Pack::REF_DELTA) { $baseId = $this->read($this->repo->hashLen); $raw .= $baseId; } else if (!isset(Pack::TYPES[$type])) { throw new GitException("壊れたパック(種類{$type})"); } hash_update($crc, $raw); $dataAt = $this->pos; // zlibのデータ(長さは展開しないと分からない) $ctx = inflate_init(ZLIB_ENCODING_DEFLATE); $out = ''; $readBefore = 0; while (true) { if ($this->buf === '') { $s = fread($this->in, 65536); if ($s === false || $s === '') { if (feof($this->in)) throw new GitException('パックが途中で終わりました'); continue; } $this->buf = $s; } $res = @inflate_add($ctx, $this->buf, ZLIB_SYNC_FLUSH); if ($res === false) throw new GitException('壊れたパック(zlib)'); $out .= $res; if (strlen($out) > $size) throw new GitException('壊れたパック(大きさ)'); $total = inflate_get_read_len($ctx); $used = $total - $readBefore; $readBefore = $total; $chunk = substr($this->buf, 0, $used); $this->buf = (string)substr($this->buf, $used); hash_update($this->hash, $chunk); hash_update($crc, $chunk); fwrite($this->tmp, $chunk); $this->pos += $used; if (inflate_get_status($ctx) === ZLIB_STREAM_END) break; } if (strlen($out) !== $size) throw new GitException('壊れたパック(大きさ)'); $this->entries[$offset] = [$type, $size, $dataAt, $baseOff, $baseId, unpack('N', hash_final($crc, true))[1]]; if (isset(Pack::TYPES[$type])) $this->found($offset, Pack::TYPES[$type], $out); } /** IDが分かったオブジェクト */ private function found(int $off, string $type, string $data): void { $id = $this->repo->hashObject($type, $data); $this->ids[$off] = hex2bin($id); $this->byId[$id] = $off; $this->types[$off] = $type; $this->check($id, $type, $data); } /** 中身の確認と、参照しているID */ private function check(string $id, string $type, string $data): void { $repo = $this->repo; try { if ($type === 'commit') { $c = Commit::parse($data); if (!$repo->isId($c->tree) || $c->author === null || $c->committer === null) throw new GitException("壊れたコミット:{$id}"); $this->links[$c->tree] = true; foreach ($c->parents as $p) { if (!$repo->isId($p)) throw new GitException("壊れたコミット:{$id}"); $this->links[$p] = true; } } else if ($type === 'tree') { foreach (Tree::parse($data, $repo->hashLen)->entries as [$mode, $name, $eid]) { if (!in_array($mode, [Tree::FILE, Tree::EXEC, Tree::LINK, Tree::DIR, Tree::SUBMODULE, '100664'], true) || $name === '' || $name === '.' || $name === '..' || str_contains($name, '/') || strcasecmp($name, '.git') === 0) { throw new GitException("不正なツリー:{$id}({$name})"); } if ($mode !== Tree::SUBMODULE) $this->links[$eid] = true; } } else if ($type === 'tag') { $t = Tag::parse($data); if (!$repo->isId($t->object)) throw new GitException("壊れたタグ:{$id}"); $this->links[$t->object] = true; } } catch (GitException $e) { throw $e; } } /** 差分を全部解決する */ private function resolveAll(?callable $progress): void { $this->pack = new Pack($this->tmpPath, $this->repo->hashLen, false); $pending = array_keys(array_filter($this->entries, fn($e) => !isset(Pack::TYPES[$e[0]]))); $total = count($pending); while (!empty($pending)) { $left = []; foreach ($pending as $off) { if (isset($this->ids[$off])) continue; if ($this->resolve($off, true) === null) $left[] = $off; } if (count($left) === count($pending)) { // 元がパックに無い(シンパック):リポジトリの物を使う foreach ($left as $off) { $bid = $this->entries[$off][4]; if ($bid === null || !$this->repo->objects->exists(bin2hex($bid))) throw new GitException('差分の元が有りません:'.($bid === null ? '?' : bin2hex($bid))); $this->thin = true; } foreach ($left as $off) $this->resolve($off); break; } $pending = $left; if ($progress) $progress('差分の解決', $total - count($pending), $total); } } /** * オフセットのオブジェクト * * @param bool $inPackOnly 元が(まだ)分からなければnull * @return array|null [種類, データ] */ private function resolve(int $off, bool $inPackOnly = false): ?array { if (isset($this->cache[$off])) return $this->cache[$off]; [$type, $size, $dataAt, $baseOff, $baseId] = $this->entries[$off]; if (isset(Pack::TYPES[$type])) { $obj = [Pack::TYPES[$type], $this->pack->inflate($dataAt, $size)]; } else { if ($baseOff !== null) { $base = $this->resolve($baseOff, $inPackOnly); } else { $hex = bin2hex($baseId); if (isset($this->byId[$hex])) $base = $this->resolve($this->byId[$hex], $inPackOnly); else if ($inPackOnly) return null; else $base = $this->repo->objects->read($hex); } if ($base === null) return null; $obj = [$base[0], Delta::apply($base[1], $this->pack->inflate($dataAt, $size))]; if (!isset($this->ids[$off])) $this->found($off, $obj[0], $obj[1]); } $n = strlen($obj[1]); if ($n < 8388608) { while ($this->cacheBytes + $n > 67108864 && !empty($this->cache)) { $k = array_key_first($this->cache); $this->cacheBytes -= strlen($this->cache[$k][1]); unset($this->cache[$k]); } $this->cache[$off] = $obj; $this->cacheBytes += $n; } return $obj; } /** パックと.idx・.revを置く */ private function install(string $sum): string { $h = $this->repo->hashLen; $sorted = $this->ids; asort($sorted, SORT_STRING); $fan = array_fill(0, 256, 0); foreach ($sorted as $bin) $fan[ord($bin[0])]++; $idx = "\xfftOc".pack('N', 2); $acc = 0; for ($i = 0; $i < 256; $i++) { $acc += $fan[$i]; $idx .= pack('N', $acc); } $names = ''; $crcs = ''; $offs = ''; $large = ''; $nLarge = 0; $position = []; $n = 0; foreach ($sorted as $off => $bin) { $names .= $bin; $crcs .= pack('N', $this->entries[$off][5]); if ($off < 0x80000000) { $offs .= pack('N', $off); } else { $offs .= pack('N', 0x80000000 | $nLarge++); $large .= pack('J', $off); } $position[$off] = $n++; } $idx .= $names.$crcs.$offs.$large.$sum; $idx .= hash($this->repo->hashAlgo, $idx, true); ksort($position); $rev = 'RIDX'.pack('NN', 1, $this->repo->hashAlgo === 'sha1' ? 1 : 2).pack('N*', ...array_values($position)).$sum; $rev .= hash($this->repo->hashAlgo, $rev, true); $base = $this->repo->objects->dir().'/pack/pack-'.bin2hex($sum); $this->pack = null; fclose($this->tmp); if (!is_file($base.'.pack')) { if (!@rename($this->tmpPath, $base.'.pack')) throw new GitException('パックを保存出来ません'); $this->repo->chmodFile($base.'.pack', true); } $this->repo->writeFile($base.'.rev', $rev, true); $this->repo->writeFile($base.'.idx', $idx, true); $this->repo->objects->packs(true); return $base.'.pack'; } }