| 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-receive-pack(プッシュ)。HTTP(stateless)とSSH |
| 42 | * |
| 43 | * 対応:report-status、delete-refs、side-band-64k、quiet、atomic、ofs-delta、no-thin(相手はシンパックを送らない)。 |
| 44 | * 受け取ったオブジェクトは確認し(壊れたオブジェクト、足りないオブジェクト)、参照はロックして更新する。 |
| 45 | */ |
| 46 | final class ReceivePack { |
| 47 | private string $agent; |
| 48 | private int $unpackLimit; |
| 49 | private Output $out; |
| 50 | private bool $sideband = false; |
| 51 | |
| 52 | /** |
| 53 | * @param array $opt agent、unpackLimit(これより少ないオブジェクトはルーズにする、既定100)、 |
| 54 | * check(fn(array $commands): ?string、全部の更新の前、エラーのメッセージで全部拒否)、 |
| 55 | * update(fn(string $ref, string $old, string $new): ?string、参照毎、エラーならその参照を拒否)、 |
| 56 | * after(fn(array $commands)、更新した参照 [ref, old, new]、結果を送った後)、 |
| 57 | * intercept(fn(string $ref): bool、trueの参照は書かずにprocessに渡す。AGitのrefs/for/等)、 |
| 58 | * process(fn(array $commands): array、成功した参照 [ref, old, new]、結果を送る前。返した行はクライアントに表示する)、 |
| 59 | * denyNonFastForwards、denyDeletes(既定はconfigのreceive.*) |
| 60 | */ |
| 61 | public function __construct(private Repository $repo, private array $opt = []) { |
| 62 | $this->agent = (string)($opt['agent'] ?? 'LittleBeast'); |
| 63 | $this->unpackLimit = (int)($opt['unpackLimit'] ?? $repo->config->getInt('receive.unpacklimit', $repo->config->getInt('transfer.unpacklimit', 100))); |
| 64 | } |
| 65 | |
| 66 | /** |
| 67 | * 参照の広告 |
| 68 | * |
| 69 | * @param resource $out |
| 70 | */ |
| 71 | public function advertise($out, bool $http): void { |
| 72 | $o = new Output($out, $http); |
| 73 | $s = $http ? PktLine::encode("# service=git-receive-pack\n").PktLine::flush() : ''; |
| 74 | $caps = "report-status delete-refs side-band-64k quiet atomic ofs-delta no-thin object-format={$this->repo->hashAlgo} agent={$this->agent}"; |
| 75 | $first = true; |
| 76 | foreach ($this->repo->refs->all() as $name => $id) { |
| 77 | $s .= PktLine::encode("{$id} {$name}".($first ? "\0".$caps : '')."\n"); |
| 78 | $first = false; |
| 79 | } |
| 80 | if ($first) $s .= PktLine::encode($this->repo->zeroId()." capabilities^{}\0{$caps}\n"); |
| 81 | $o->write($s.PktLine::flush()); |
| 82 | $o->flush(); |
| 83 | } |
| 84 | |
| 85 | /** |
| 86 | * プッシュを処理する |
| 87 | * |
| 88 | * @param resource $in |
| 89 | * @param resource $out |
| 90 | * @return array 更新した参照 [ref, old, new] |
| 91 | */ |
| 92 | public function serve($in, $out, bool $statelessRpc): array { |
| 93 | $this->out = new Output($out, $statelessRpc); |
| 94 | if (!$statelessRpc) $this->advertise($out, false); |
| 95 | $pkt = new PktLine($in); |
| 96 | $commands = []; |
| 97 | $caps = []; |
| 98 | $zero = $this->repo->zeroId(); |
| 99 | while (true) { |
| 100 | $l = $pkt->read(); |
| 101 | if ($l === null || $l === PktLine::FLUSH) break; |
| 102 | if (!is_string($l)) continue; |
| 103 | if (str_starts_with($l, 'shallow ')) continue; |
| 104 | $nul = strpos($l, "\0"); |
| 105 | if ($nul !== false) { |
| 106 | $caps = explode(' ', trim(substr($l, $nul + 1))); |
| 107 | $l = substr($l, 0, $nul); |
| 108 | } |
| 109 | if (!preg_match('/^([0-9a-f]+) ([0-9a-f]+) (.+)$/', $l, $m) || !$this->repo->isId($m[1]) || !$this->repo->isId($m[2])) { |
| 110 | throw new GitException("不正なコマンド:{$l}"); |
| 111 | } |
| 112 | $commands[] = ['ref' => $m[3], 'old' => $m[1], 'new' => $m[2], 'error' => null]; |
| 113 | } |
| 114 | if (empty($commands)) return []; |
| 115 | foreach ($caps as $c) { |
| 116 | if (str_starts_with($c, 'object-format=') && substr($c, 14) !== $this->repo->hashAlgo) throw new GitException('オブジェクトの形式が違います'); |
| 117 | } |
| 118 | $this->sideband = in_array('side-band-64k', $caps, true); |
| 119 | $quiet = in_array('quiet', $caps, true); |
| 120 | $atomic = in_array('atomic', $caps, true); |
| 121 | $report = in_array('report-status', $caps, true); |
| 122 | |
| 123 | // パック(削除だけなら無い) |
| 124 | $unpack = 'ok'; |
| 125 | $received = ['objects' => [], 'links' => []]; |
| 126 | $needPack = false; |
| 127 | foreach ($commands as $c) if ($c['new'] !== $zero) $needPack = true; |
| 128 | if ($needPack) { |
| 129 | try { |
| 130 | $progress = $quiet || !$this->sideband ? null : function(string $what, int $done, int $total): void { |
| 131 | $this->out->write(PktLine::band(2, "{$what}:{$done}/{$total}".($done === $total ? "\n" : "\r"))); |
| 132 | $this->out->flush(); |
| 133 | }; |
| 134 | $received = (new PackIndexer($this->repo))->receive($in, $this->unpackLimit, $progress); |
| 135 | // 足りないオブジェクト |
| 136 | foreach (array_keys($received['links']) as $id) { |
| 137 | if (!$this->repo->objects->exists($id)) throw new GitException("必要なオブジェクトが有りません:{$id}"); |
| 138 | } |
| 139 | } catch (GitException $e) { |
| 140 | $unpack = $e->getMessage(); |
| 141 | } |
| 142 | } |
| 143 | if ($unpack !== 'ok') { |
| 144 | foreach ($commands as $i => $c) $commands[$i]['error'] = 'unpacker error'; |
| 145 | } else { |
| 146 | $commands = $this->check($commands, $received['objects']); |
| 147 | if ($atomic && count(array_filter($commands, fn($c) => $c['error'] !== null)) > 0) { |
| 148 | foreach ($commands as $i => $c) $commands[$i]['error'] ??= 'atomic push failed'; |
| 149 | } |
| 150 | $commands = $this->apply($commands, $atomic); |
| 151 | // 参照の処理(プルリクエスト等)とクライアントへのメッセージ |
| 152 | if (isset($this->opt['process'])) { |
| 153 | $ok = array_values(array_map(fn($c) => [$c['ref'], $c['old'], $c['new']], array_filter($commands, fn($c) => $c['error'] === null))); |
| 154 | $lines = empty($ok) ? [] : ($this->opt['process'])($ok); |
| 155 | if ($this->sideband && !empty($lines)) { |
| 156 | foreach ($lines as $line) $this->out->write(PktLine::band(2, rtrim((string)$line, "\n")."\n")); |
| 157 | $this->out->flush(); |
| 158 | } |
| 159 | } |
| 160 | } |
| 161 | |
| 162 | if ($report) { |
| 163 | $s = PktLine::encode('unpack '.($unpack === 'ok' ? 'ok' : str_replace("\n", ' ', $unpack))."\n"); |
| 164 | foreach ($commands as $c) $s .= PktLine::encode($c['error'] === null ? "ok {$c['ref']}\n" : "ng {$c['ref']} ".str_replace("\n", ' ', $c['error'])."\n"); |
| 165 | $s .= PktLine::flush(); |
| 166 | $this->out->write($this->sideband ? PktLine::band(1, $s) : $s); |
| 167 | } |
| 168 | if ($this->sideband) $this->out->write(PktLine::flush()); |
| 169 | $this->out->flush(); |
| 170 | |
| 171 | $done = []; |
| 172 | foreach ($commands as $c) if ($c['error'] === null) $done[] = [$c['ref'], $c['old'], $c['new']]; |
| 173 | if (!empty($done) && isset($this->opt['after'])) ($this->opt['after'])($done); |
| 174 | return $done; |
| 175 | } |
| 176 | |
| 177 | /** 更新出来るか確かめる */ |
| 178 | private function check(array $commands, array $received): array { |
| 179 | $zero = $this->repo->zeroId(); |
| 180 | $denyFf = (bool)($this->opt['denyNonFastForwards'] ?? $this->repo->config->getBool('receive.denynonfastforwards')); |
| 181 | $denyDel = (bool)($this->opt['denyDeletes'] ?? $this->repo->config->getBool('receive.denydeletes')); |
| 182 | if (isset($this->opt['check'])) { |
| 183 | $err = ($this->opt['check'])(array_map(fn($c) => [$c['ref'], $c['old'], $c['new']], $commands)); |
| 184 | if ($err !== null) { |
| 185 | foreach ($commands as $i => $c) $commands[$i]['error'] = $err; |
| 186 | return $commands; |
| 187 | } |
| 188 | } |
| 189 | $walk = new Walk($this->repo); |
| 190 | foreach ($commands as $i => $c) { |
| 191 | $err = null; |
| 192 | $ref = $c['ref']; |
| 193 | $type = $c['new'] === $zero ? null : ($received[$c['new']] ?? $this->repo->objects->info($c['new'])[0] ?? null); |
| 194 | if (!Refs::validName($ref) || !str_starts_with($ref, 'refs/') || $ref === 'HEAD') $err = 'funny refname'; |
| 195 | else if ($c['new'] !== $zero && $type === null) $err = 'missing necessary objects'; |
| 196 | else if (str_starts_with($ref, 'refs/heads/') && $c['new'] !== $zero && $type !== 'commit') $err = 'branch must point to a commit'; |
| 197 | else if ($c['new'] === $zero && $denyDel) $err = 'deletion prohibited'; |
| 198 | else if ($c['new'] === $zero && $c['old'] === $zero) $err = 'nothing to delete'; |
| 199 | else if ($denyFf && $c['old'] !== $zero && $c['new'] !== $zero && $this->repo->objects->exists($c['old']) |
| 200 | && !$walk->isAncestor($c['old'], $c['new'])) $err = 'non-fast-forward'; |
| 201 | if ($err === null && isset($this->opt['update'])) $err = ($this->opt['update'])($ref, $c['old'], $c['new']); |
| 202 | $commands[$i]['error'] = $err; |
| 203 | } |
| 204 | return $commands; |
| 205 | } |
| 206 | |
| 207 | /** 参照を更新する(古い値が変わっていれば失敗) */ |
| 208 | private function apply(array $commands, bool $atomic): array { |
| 209 | $zero = $this->repo->zeroId(); |
| 210 | // interceptの参照は書かない |
| 211 | $skip = fn(array $c) => isset($this->opt['intercept']) && ($this->opt['intercept'])($c['ref']); |
| 212 | $ok = array_filter($commands, fn($c) => $c['error'] === null && !$skip($c)); |
| 213 | if (empty($ok)) return $commands; |
| 214 | $tx = fn(array $c) => [$c['ref'], $c['new'] === $zero ? null : $c['new'], $c['old']]; |
| 215 | if ($atomic) { |
| 216 | try { |
| 217 | $this->repo->refs->transaction(array_map($tx, $ok)); |
| 218 | } catch (GitException $e) { |
| 219 | foreach ($commands as $i => $c) $commands[$i]['error'] ??= 'failed to update ref'; |
| 220 | } |
| 221 | return $commands; |
| 222 | } |
| 223 | foreach ($commands as $i => $c) { |
| 224 | if ($c['error'] !== null || $skip($c)) continue; |
| 225 | try { |
| 226 | $this->repo->refs->transaction([$tx($c)]); |
| 227 | } catch (GitException $e) { |
| 228 | $commands[$i]['error'] = str_contains($e->getMessage(), '変わって') ? 'fetch first' : 'failed to update ref'; |
| 229 | } |
| 230 | } |
| 231 | return $commands; |
| 232 | } |
| 233 | } |