| 1 | <?php |
| 2 | /************************************************************* |
| 3 | # 076 License |
| 4 | |
| 5 | Copyright (c) テクニカル諏訪子 |
| 6 | |
| 7 | Permission is hereby granted to any person obtaining a copy of the software |
| 8 | Little Beast (the "Software") to use, modify, merge, copy, publish, distribute, |
| 9 | sublicense, and/or sell copies of the Software, subject to the following conditions: |
| 10 | |
| 11 | 1. **Origin Attribution**: |
| 12 | - You must not misrepresent the origin of the Software; you must not claim |
| 13 | you created the original Software. |
| 14 | - If the Software is used in a product, you must either: |
| 15 | a. Provide clear attribution in the product's documentation, user interface, |
| 16 | or other visible areas, **OR** |
| 17 | b. Pay the original developers a fee they specify in writing. |
| 18 | 2. **Usage Restriction**: |
| 19 | - The Software, or any derivative works, dependencies, or libraries |
| 20 | incorporating it, must not be used for censorship or to suppress freedom of |
| 21 | speech, expression, or creativity. Prohibited uses include, but are not |
| 22 | limited to: |
| 23 | - Censorship of so-called "hate speech", visuals, non-mainstream opinions, |
| 24 | ideas, or objective reality. |
| 25 | - Tools or systems designed to restrict access to information or |
| 26 | artistic works. |
| 27 | 3. **Notice Preservation**: |
| 28 | - This license and the above copyright notice must remain intact in all copies |
| 29 | of the source code. |
| 30 | |
| 31 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, |
| 32 | INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A |
| 33 | PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT |
| 34 | HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF |
| 35 | CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE |
| 36 | OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. |
| 37 | */ |
| 38 | namespace Std\Lib\Git; |
| 39 | |
| 40 | /** |
| 41 | * git-upload-pack(クローン・フェッチ)。プロトコルv0・v1・v2、HTTP(stateless)とSSH |
| 42 | * |
| 43 | * 対応:multi_ack_detailed、side-band・side-band-64k、ofs-delta、shallow(--depth)、include-tag、no-progress。 |
| 44 | * 欲しいオブジェクトはリポジトリの中の物なら何でも良い(アクセスの確認は呼ぶ側で)。 |
| 45 | */ |
| 46 | final class UploadPack { |
| 47 | private string $agent; |
| 48 | private Output $out; |
| 49 | private bool $sideband = false; |
| 50 | private int $bandMax = PktLine::MAX; |
| 51 | private bool $progress = true; |
| 52 | |
| 53 | /** |
| 54 | * @param array $opt agent(エージェントの名前) |
| 55 | */ |
| 56 | public function __construct(private Repository $repo, array $opt = []) { |
| 57 | $this->agent = (string)($opt['agent'] ?? 'LittleBeast'); |
| 58 | } |
| 59 | |
| 60 | /** |
| 61 | * 参照の広告(最初の応答) |
| 62 | * |
| 63 | * @param resource $out |
| 64 | * @param bool $http 「# service=git-upload-pack」を付ける(v0・v1) |
| 65 | */ |
| 66 | public function advertise($out, bool $http, int $version = 0): void { |
| 67 | $o = new Output($out, $http); |
| 68 | if ($version === 2) { |
| 69 | $o->write(PktLine::encode("version 2\n").PktLine::encode("agent={$this->agent}\n").PktLine::encode("ls-refs=unborn\n") |
| 70 | .PktLine::encode("fetch=shallow wait-for-done\n").PktLine::encode("server-option\n").PktLine::encode("object-format={$this->repo->hashAlgo}\n").PktLine::flush()); |
| 71 | $o->flush(); |
| 72 | return; |
| 73 | } |
| 74 | $s = $http ? PktLine::encode("# service=git-upload-pack\n").PktLine::flush() : ''; |
| 75 | if ($version === 1) $s .= PktLine::encode("version 1\n"); |
| 76 | $caps = 'multi_ack multi_ack_detailed side-band side-band-64k ofs-delta shallow deepen-relative no-progress include-tag thin-pack'; |
| 77 | $head = $this->repo->headRef(); |
| 78 | if ($head !== null && $this->repo->refs->resolve('HEAD') !== null) $caps .= " symref=HEAD:{$head}"; |
| 79 | $caps .= " object-format={$this->repo->hashAlgo} agent={$this->agent}"; |
| 80 | $first = true; |
| 81 | foreach ($this->refLines() as [$id, $name]) { |
| 82 | $s .= PktLine::encode($id.' '.$name.($first ? "\0".$caps : '')."\n"); |
| 83 | $first = false; |
| 84 | } |
| 85 | if ($first) $s .= PktLine::encode($this->repo->zeroId()." capabilities^{}\0{$caps}\n"); |
| 86 | $o->write($s.PktLine::flush()); |
| 87 | $o->flush(); |
| 88 | } |
| 89 | |
| 90 | /** HEADと参照(注釈付きタグは剥いた物も) */ |
| 91 | private function refLines(): array { |
| 92 | $out = []; |
| 93 | $head = $this->repo->refs->resolve('HEAD'); |
| 94 | if ($head !== null) $out[] = [$head, 'HEAD']; |
| 95 | foreach ($this->repo->refs->all() as $name => $id) { |
| 96 | $out[] = [$id, $name]; |
| 97 | if (str_starts_with($name, 'refs/tags/')) { |
| 98 | $peeled = $this->peeled($name, $id); |
| 99 | if ($peeled !== null && $peeled !== $id) $out[] = [$peeled, $name.'^{}']; |
| 100 | } |
| 101 | } |
| 102 | return $out; |
| 103 | } |
| 104 | |
| 105 | private function peeled(string $name, string $id): ?string { |
| 106 | return $this->repo->refs->peeledFromPack($name) ?? $this->repo->peel($id); |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * リクエストを処理する |
| 111 | * |
| 112 | * @param resource $in |
| 113 | * @param resource $out |
| 114 | * @param bool $statelessRpc HTTP(1回のリクエストと応答) |
| 115 | */ |
| 116 | public function serve($in, $out, bool $statelessRpc, int $version = 0): void { |
| 117 | $this->out = new Output($out, $statelessRpc); |
| 118 | $pkt = new PktLine($in); |
| 119 | try { |
| 120 | if ($version === 2) { |
| 121 | if (!$statelessRpc) $this->advertise($out, false, 2); |
| 122 | do { |
| 123 | $more = $this->command($pkt); |
| 124 | } while ($more && !$statelessRpc); |
| 125 | return; |
| 126 | } |
| 127 | if (!$statelessRpc) $this->advertise($out, false, $version); |
| 128 | $this->fetchV0($pkt, $statelessRpc); |
| 129 | } catch (GitException $e) { |
| 130 | if ($this->sideband) $this->out->write(PktLine::band(3, $e->getMessage()."\n", $this->bandMax).PktLine::flush()); |
| 131 | else $this->out->write(PktLine::encode('ERR '.$e->getMessage()."\n")); |
| 132 | $this->out->flush(); |
| 133 | } |
| 134 | } |
| 135 | |
| 136 | //////// v0・v1 |
| 137 | |
| 138 | private function fetchV0(PktLine $pkt, bool $stateless): void { |
| 139 | $wants = []; |
| 140 | $caps = []; |
| 141 | $clientShallow = []; |
| 142 | $depth = 0; |
| 143 | $relative = false; |
| 144 | while (true) { |
| 145 | $l = $pkt->read(); |
| 146 | if ($l === null || $l === PktLine::FLUSH) break; |
| 147 | if (!is_string($l)) continue; |
| 148 | if (preg_match('/^want ([0-9a-f]+)(?: (.*))?$/', $l, $m)) { |
| 149 | if (empty($wants) && isset($m[2])) $caps = explode(' ', trim($m[2])); |
| 150 | $this->checkWant($m[1]); |
| 151 | $wants[$m[1]] = true; |
| 152 | } else if (preg_match('/^shallow ([0-9a-f]+)$/', $l, $m)) { |
| 153 | $clientShallow[] = $m[1]; |
| 154 | } else if (preg_match('/^deepen (\d+)$/', $l, $m)) { |
| 155 | $depth = (int)$m[1]; |
| 156 | } else if ($l === 'deepen-relative') { |
| 157 | $relative = true; |
| 158 | } else if (preg_match('/^(deepen-since|deepen-not|filter) /', $l, $m)) { |
| 159 | throw new GitException("未対応:{$m[1]}"); |
| 160 | } |
| 161 | } |
| 162 | if (empty($wants)) return; |
| 163 | // v0では能力として送られる |
| 164 | if (in_array('deepen-relative', $caps, true)) $relative = true; |
| 165 | $this->sideband = in_array('side-band-64k', $caps, true) || in_array('side-band', $caps, true); |
| 166 | $this->bandMax = in_array('side-band-64k', $caps, true) ? PktLine::MAX : 1000; |
| 167 | $this->progress = !in_array('no-progress', $caps, true); |
| 168 | $multiAck = in_array('multi_ack_detailed', $caps, true) ? 2 : (in_array('multi_ack', $caps, true) ? 1 : 0); |
| 169 | |
| 170 | // 深さ |
| 171 | $commits = null; |
| 172 | $boundary = null; |
| 173 | if ($depth > 0) { |
| 174 | [$commits, $shallow] = $this->shallow(array_keys($wants), $depth, $clientShallow, $relative); |
| 175 | $s = ''; |
| 176 | foreach ($shallow['shallow'] as $id) $s .= PktLine::encode("shallow {$id}\n"); |
| 177 | foreach ($shallow['unshallow'] as $id) $s .= PktLine::encode("unshallow {$id}\n"); |
| 178 | $this->out->write($s.PktLine::flush()); |
| 179 | $this->out->flush(); |
| 180 | } |
| 181 | |
| 182 | // ネゴシエーション |
| 183 | $common = []; |
| 184 | $last = null; |
| 185 | $done = false; |
| 186 | while (true) { |
| 187 | $l = $pkt->read(); |
| 188 | if ($l === null) return; |
| 189 | if ($l === PktLine::FLUSH) { |
| 190 | if (empty($common) || $multiAck) $this->out->write(PktLine::encode("NAK\n")); |
| 191 | $this->out->flush(); |
| 192 | if ($stateless) return; |
| 193 | continue; |
| 194 | } |
| 195 | if ($l === 'done') { |
| 196 | $done = true; |
| 197 | break; |
| 198 | } |
| 199 | if (!is_string($l) || !preg_match('/^have ([0-9a-f]+)$/', $l, $m)) continue; |
| 200 | $id = $m[1]; |
| 201 | if (!$this->repo->isId($id) || !$this->repo->objects->exists($id)) continue; |
| 202 | $isNew = !isset($common[$id]); |
| 203 | $common[$id] = true; |
| 204 | $last = $id; |
| 205 | if ($multiAck === 2) $this->out->write(PktLine::encode("ACK {$id} common\n")); |
| 206 | else if ($multiAck === 1) $this->out->write(PktLine::encode("ACK {$id} continue\n")); |
| 207 | else if ($isNew && count($common) === 1) $this->out->write(PktLine::encode("ACK {$id}\n")); |
| 208 | } |
| 209 | if (!empty($common)) { |
| 210 | if ($multiAck) $this->out->write(PktLine::encode("ACK {$last}\n")); |
| 211 | } else { |
| 212 | $this->out->write(PktLine::encode("NAK\n")); |
| 213 | } |
| 214 | $this->sendPack(array_keys($wants), array_keys($common), in_array('include-tag', $caps, true), $commits, $clientShallow, $depth > 0); |
| 215 | } |
| 216 | |
| 217 | private function checkWant(string $id): void { |
| 218 | if (!$this->repo->isId($id) || !$this->repo->objects->exists($id)) throw new GitException("オブジェクトが有りません:{$id}"); |
| 219 | } |
| 220 | |
| 221 | /** |
| 222 | * 深さの制限 |
| 223 | * |
| 224 | * @return array [送るコミット, ['shallow' => [...], 'unshallow' => [...]]] |
| 225 | */ |
| 226 | private function shallow(array $wants, int $depth, array $clientShallow, bool $relative = false): array { |
| 227 | $walk = new Walk($this->repo); |
| 228 | $wantCommits = []; |
| 229 | foreach ($wants as $w) { |
| 230 | $c = $this->repo->peel($w, 'commit'); |
| 231 | if ($c !== null) $wantCommits[] = $c; |
| 232 | } |
| 233 | if ($relative && !empty($clientShallow)) { |
| 234 | // 今のshallowのコミットから更に深く(git fetch --deepen) |
| 235 | [$deeper, $shallow] = $walk->deepen($clientShallow, $depth + 1); |
| 236 | $grafted = new Walk($this->repo); |
| 237 | $grafted->setGrafts($clientShallow); |
| 238 | $commits = array_values(array_unique(array_merge($grafted->ancestors($wantCommits), $deeper))); |
| 239 | $sh = array_fill_keys($shallow, true); |
| 240 | return [$commits, ['shallow' => $shallow, 'unshallow' => array_values(array_filter($clientShallow, fn($id) => !isset($sh[$id])))]]; |
| 241 | } |
| 242 | [$commits, $shallow] = $walk->deepen($wantCommits, $depth); |
| 243 | $in = array_fill_keys($commits, true); |
| 244 | $sh = array_fill_keys($shallow, true); |
| 245 | $unshallow = []; |
| 246 | foreach ($clientShallow as $id) if (isset($in[$id]) && !isset($sh[$id])) $unshallow[] = $id; |
| 247 | return [$commits, ['shallow' => $shallow, 'unshallow' => $unshallow]]; |
| 248 | } |
| 249 | |
| 250 | /** パックを送る(サイドバンドが有れば進捗も) */ |
| 251 | private function sendPack(array $wants, array $haves, bool $includeTag, ?array $commits, array $clientShallow, bool $deepen): void { |
| 252 | $walk = new Walk($this->repo); |
| 253 | $walk->setGrafts($clientShallow); |
| 254 | $haveCommits = []; |
| 255 | foreach ($haves as $h) { |
| 256 | $c = $this->repo->peel($h, 'commit'); |
| 257 | if ($c !== null) $haveCommits[] = $c; |
| 258 | } |
| 259 | $boundary = null; |
| 260 | if ($commits !== null) { |
| 261 | // 深さで決めたコミットから、相手が持っている物を除く |
| 262 | $have = array_fill_keys($walk->ancestors($haveCommits), true); |
| 263 | $commits = array_values(array_filter($commits, fn($c) => !isset($have[$c]))); |
| 264 | $boundary = []; |
| 265 | foreach ($commits as $c) foreach ($this->repo->commit($c)->parents as $p) if (isset($have[$p])) $boundary[$p] = true; |
| 266 | $boundary = array_keys($boundary); |
| 267 | } |
| 268 | $this->progress('オブジェクトを数えています…'); |
| 269 | $objects = $walk->objects($wants, $haveCommits, $commits, $boundary); |
| 270 | if ($includeTag) { |
| 271 | foreach ($this->repo->refs->all('refs/tags/') as $name => $id) { |
| 272 | if (isset($objects[$id]) || ($this->repo->objects->info($id)[0] ?? null) !== 'tag') continue; |
| 273 | $target = $this->peeled($name, $id); |
| 274 | if ($target !== null && isset($objects[$target])) $objects[$id] = 'tag'; |
| 275 | } |
| 276 | } |
| 277 | $this->progress('オブジェクト:'.count($objects)."件\n"); |
| 278 | $writer = new PackWriter($this->repo); |
| 279 | $writer->write($objects, function(string $bytes): void { |
| 280 | $this->out->write($this->sideband ? PktLine::band(1, $bytes, $this->bandMax) : $bytes); |
| 281 | $this->out->flush(); |
| 282 | }); |
| 283 | if ($this->sideband) $this->out->write(PktLine::flush()); |
| 284 | $this->out->flush(); |
| 285 | } |
| 286 | |
| 287 | private function progress(string $msg): void { |
| 288 | if ($this->sideband && $this->progress) { |
| 289 | $this->out->write(PktLine::band(2, str_ends_with($msg, "\n") ? $msg : $msg."\r", $this->bandMax)); |
| 290 | $this->out->flush(); |
| 291 | } |
| 292 | } |
| 293 | |
| 294 | //////// v2 |
| 295 | |
| 296 | /** コマンドを1つ処理する(続けるならtrue) */ |
| 297 | private function command(PktLine $pkt): bool { |
| 298 | $command = null; |
| 299 | $caps = []; |
| 300 | while (true) { |
| 301 | $l = $pkt->read(); |
| 302 | if ($l === null) return false; |
| 303 | if ($l === PktLine::FLUSH) { |
| 304 | if ($command === null) return true; |
| 305 | $args = []; |
| 306 | break; |
| 307 | } |
| 308 | if ($l === PktLine::DELIM) { |
| 309 | $args = $pkt->readUntilFlush(); |
| 310 | break; |
| 311 | } |
| 312 | if (is_string($l) && str_starts_with($l, 'command=')) $command = substr($l, 8); |
| 313 | else if (is_string($l)) $caps[] = $l; |
| 314 | } |
| 315 | foreach ($caps as $c) { |
| 316 | if (str_starts_with($c, 'object-format=') && substr($c, 14) !== $this->repo->hashAlgo) throw new GitException('オブジェクトの形式が違います('.substr($c, 14).')'); |
| 317 | } |
| 318 | match ($command) { |
| 319 | 'ls-refs' => $this->lsRefs($args), |
| 320 | 'fetch' => $this->fetchV2($args), |
| 321 | default => throw new GitException("未対応のコマンド:{$command}"), |
| 322 | }; |
| 323 | return true; |
| 324 | } |
| 325 | |
| 326 | private function lsRefs(array $args): void { |
| 327 | $symrefs = in_array('symrefs', $args, true); |
| 328 | $peel = in_array('peel', $args, true); |
| 329 | $unborn = in_array('unborn', $args, true); |
| 330 | $prefixes = []; |
| 331 | foreach ($args as $a) if (str_starts_with($a, 'ref-prefix ')) $prefixes[] = substr($a, 11); |
| 332 | $match = function(string $name) use ($prefixes): bool { |
| 333 | if (empty($prefixes)) return true; |
| 334 | foreach ($prefixes as $p) if (str_starts_with($name, $p)) return true; |
| 335 | return false; |
| 336 | }; |
| 337 | $s = ''; |
| 338 | if ($match('HEAD')) { |
| 339 | $head = $this->repo->refs->resolve('HEAD'); |
| 340 | $target = $this->repo->headRef(); |
| 341 | $sym = $symrefs && $target !== null ? " symref-target:{$target}" : ''; |
| 342 | if ($head !== null) $s .= PktLine::encode("{$head} HEAD{$sym}\n"); |
| 343 | else if ($unborn && $target !== null) $s .= PktLine::encode("unborn HEAD{$sym}\n"); |
| 344 | } |
| 345 | foreach ($this->repo->refs->all() as $name => $id) { |
| 346 | if (!$match($name)) continue; |
| 347 | $line = "{$id} {$name}"; |
| 348 | if ($peel && str_starts_with($name, 'refs/tags/')) { |
| 349 | $p = $this->peeled($name, $id); |
| 350 | if ($p !== null && $p !== $id) $line .= " peeled:{$p}"; |
| 351 | } |
| 352 | $s .= PktLine::encode($line."\n"); |
| 353 | } |
| 354 | $this->out->write($s.PktLine::flush()); |
| 355 | $this->out->flush(); |
| 356 | } |
| 357 | |
| 358 | private function fetchV2(array $args): void { |
| 359 | $wants = []; |
| 360 | $haves = []; |
| 361 | $clientShallow = []; |
| 362 | $depth = 0; |
| 363 | $relative = false; |
| 364 | $done = false; |
| 365 | $includeTag = false; |
| 366 | foreach ($args as $a) { |
| 367 | if (preg_match('/^want ([0-9a-f]+)$/', $a, $m)) { |
| 368 | $this->checkWant($m[1]); |
| 369 | $wants[$m[1]] = true; |
| 370 | } else if (preg_match('/^have ([0-9a-f]+)$/', $a, $m)) { |
| 371 | if ($this->repo->isId($m[1]) && $this->repo->objects->exists($m[1])) $haves[$m[1]] = true; |
| 372 | } else if (preg_match('/^shallow ([0-9a-f]+)$/', $a, $m)) { |
| 373 | $clientShallow[] = $m[1]; |
| 374 | } else if (preg_match('/^deepen (\d+)$/', $a, $m)) { |
| 375 | $depth = (int)$m[1]; |
| 376 | } else if ($a === 'deepen-relative') { |
| 377 | $relative = true; |
| 378 | } else if ($a === 'done') { |
| 379 | $done = true; |
| 380 | } else if ($a === 'include-tag') { |
| 381 | $includeTag = true; |
| 382 | } else if ($a === 'no-progress') { |
| 383 | $this->progress = false; |
| 384 | } else if (preg_match('/^(deepen-since|deepen-not|filter|want-ref|packfile-uris) /', $a, $m)) { |
| 385 | throw new GitException("未対応:{$m[1]}"); |
| 386 | } |
| 387 | } |
| 388 | if (!$done) { |
| 389 | // 共通のコミットを知らせる(readyは送らないので、相手がdoneを送る) |
| 390 | $s = PktLine::encode("acknowledgments\n"); |
| 391 | if (empty($haves)) $s .= PktLine::encode("NAK\n"); |
| 392 | foreach (array_keys($haves) as $h) $s .= PktLine::encode("ACK {$h}\n"); |
| 393 | $this->out->write($s.PktLine::flush()); |
| 394 | $this->out->flush(); |
| 395 | return; |
| 396 | } |
| 397 | $commits = null; |
| 398 | if ($depth > 0) { |
| 399 | [$commits, $shallow] = $this->shallow(array_keys($wants), $depth, $clientShallow, $relative); |
| 400 | $s = PktLine::encode("shallow-info\n"); |
| 401 | foreach ($shallow['shallow'] as $id) $s .= PktLine::encode("shallow {$id}\n"); |
| 402 | foreach ($shallow['unshallow'] as $id) $s .= PktLine::encode("unshallow {$id}\n"); |
| 403 | $this->out->write($s.PktLine::delim()); |
| 404 | } |
| 405 | $this->out->write(PktLine::encode("packfile\n")); |
| 406 | $this->sideband = true; |
| 407 | $this->bandMax = PktLine::MAX; |
| 408 | $this->sendPack(array_keys($wants), array_keys($haves), $includeTag, $commits, $clientShallow, $depth > 0); |
| 409 | } |
| 410 | } |