SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ src / library / version_control / GitReceivePack.php⤓
<?php
/**
 * SeekQuarry/Yioop --
 * Open Source Pure PHP Search Engine, Crawler, and Indexer
 *
 * Copyright (C) 2009 - 2026  Chris Pollett chris@pollett.org
 *
 * LICENSE:
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program.  If not, see <https://www.gnu.org/licenses/>.
 *
 * END LICENSE
 *
 * @author Chris Pollett chris@pollett.org
 * @license https://www.gnu.org/licenses/ GPL3
 * @link https://www.seekquarry.com/
 * @copyright 2009 - 2026
 * @filesource
 */
namespace seekquarry\yioop\library\version_control;

/**
 * GitReceivePack takes in a push over the "smart" side of Git's HTTP
 * protocol, in pure PHP with no git program involved. A client first reads
 * the list of branches and tags from advertiseRefs. It then posts a list
 * of commands, each saying which branch or tag should move from which
 * commit to which, followed by a pack file holding the objects the server
 * lacks. receive stores each object of the pack as its own compressed
 * file in the repository's objects folder, moves the refs, and answers
 * with a report saying which commands worked. A ref is git's name for a
 * branch or tag, kept as a file holding the object name it points at.
 *
 * Unlike the older "dumb" push, which sends one WebDAV request (the
 * protocol for writing files on a web server) per file, a smart push is
 * started by the same git program that handles sign-in, so a person who
 * types their user name and code at the prompt, or keeps them with a
 * credential helper, can push.
 *
 * @author Chris Pollett
 */
class GitReceivePack
{
    /**
     * RECEIVE_PACK_SERVICE the name a client gives, in the service query
     * argument and in the path it posts to, when it wants to push
     */
    const RECEIVE_PACK_SERVICE = "git-receive-pack";
    /**
     * CAPABILITIES the things this server tells a pushing client it can do:
     * report-status asks for a report of how each command went,
     * delete-refs lets a push delete a branch, and ofs-delta lets the pack
     * store an object as its differences from an object earlier in the
     * same pack
     */
    const CAPABILITIES = "report-status delete-refs ofs-delta";
    /**
     * ZERO_NAME the object name a command uses for "no object": as its old
     * value when it makes a new ref, and as its new value when it deletes
     * one
     */
    const ZERO_NAME = "0000000000000000000000000000000000000000";
    /**
     * PACK_SIGNATURE the four bytes a pack file starts with
     */
    const PACK_SIGNATURE = "PACK";
    /**
     * PACK_HEADER_LENGTH the length of a pack file's header: its
     * signature, its version and its object count, four bytes each
     */
    const PACK_HEADER_LENGTH = 12;
    /**
     * PACK_TRAILER_LENGTH the length of the SHA-1 checksum, the 20-byte
     * hash of every byte before it, that ends a pack file
     */
    const PACK_TRAILER_LENGTH = 20;
    /**
     * TYPE_NAMES the kind of object each type number in a pack stands for,
     * for the four kinds stored whole
     */
    const TYPE_NAMES = [1 => "commit", 2 => "tree", 3 => "blob",
        4 => "tag"];
    /**
     * TYPE_OFS_DELTA the type number of an object stored as its
     * differences from an object at an earlier place in the same pack
     */
    const TYPE_OFS_DELTA = 6;
    /**
     * TYPE_REF_DELTA the type number of an object stored as its
     * differences from an object named by its 20-byte object name
     */
    const TYPE_REF_DELTA = 7;
    /**
     * RAW_NAME_LENGTH the length of an object name as 20 raw bytes, as a
     * ref-delta gives its base
     */
    const RAW_NAME_LENGTH = 20;
    /**
     * LOW_SEVEN_BITS the bits of a byte that carry a number in git's
     * variable-length encodings, the eighth bit saying another byte
     * follows
     */
    const LOW_SEVEN_BITS = 0x7f;
    /**
     * CONTINUATION_FLAG the eighth bit of a byte in a variable-length
     * number, set when another byte follows
     */
    const CONTINUATION_FLAG = 0x80;
    /**
     * FIRST_SIZE_MASK the bits of an object header's first byte that hold
     * the low four bits of the object's size
     */
    const FIRST_SIZE_MASK = 0x0f;
    /**
     * TYPE_SHIFT how far the type number sits from the bottom of an
     * object header's first byte
     */
    const TYPE_SHIFT = 4;
    /**
     * TYPE_MASK the bits of the shifted first header byte that hold the
     * type number
     */
    const TYPE_MASK = 0x07;
    /**
     * FIRST_SIZE_BITS how many bits of an object's size the first header
     * byte holds
     */
    const FIRST_SIZE_BITS = 4;
    /**
     * MORE_SIZE_BITS how many bits of a number each further byte of a
     * variable-length encoding holds
     */
    const MORE_SIZE_BITS = 7;
    /**
     * INFLATE_STEP how many compressed bytes are given to the
     * decompressor at a time while reading one object
     */
    const INFLATE_STEP = 8192;
    /**
     * HASH_STEP how many bytes of a pack are given to the SHA-1 hash at a
     * time while its checksum is checked
     */
    const HASH_STEP = 1048576;
    /**
     * $repository reads the refs and objects of the bare repository a push
     * goes to, and gives the folder new objects and refs are written in
     * @var GitRepository
     */
    private $repository;
    /**
     * $objects_by_offset maps the place in the pack where each object read
     * so far starts to its object name, so an ofs-delta can find its base.
     * Only names are kept; each object is written to the repository as it
     * is read and its base is read back from there, so a large push does
     * not hold every object in memory at once.
     * @var array
     */
    private $objects_by_offset = [];
    /**
     * __construct sets up a receiver for pushes to one repository.
     *
     * @param GitRepository $repository the bare repository being pushed to
     */
    public function __construct($repository)
    {
        $this->repository = $repository;
    }
    /**
     * advertiseRefs builds the reply a pushing client reads first, over a
     * GET of info/refs with service=git-receive-pack. It names the service,
     * then lists each ref with the object it points at, the first line
     * carrying the server's CAPABILITIES. A repository with no refs yet
     * sends one line naming no object, as git does, so the client still
     * learns the capabilities.
     *
     * @return string the advertisement as pkt-lines, git's lines each led
     *      by its length in four hexadecimal digits
     */
    public function advertiseRefs()
    {
        $output = GitUploadPack::pktLine("# service=" .
            self::RECEIVE_PACK_SERVICE . "\n") . GitUploadPack::FLUSH_PKT;
        $refs = $this->repository->allRefs();
        if (empty($refs)) {
            $refs = ["capabilities^{}" => self::ZERO_NAME];
        }
        $first = true;
        foreach ($refs as $name => $object_name) {
            $line = $object_name . " " . $name;
            if ($first) {
                $line .= "\0" . self::CAPABILITIES;
                $first = false;
            }
            $output .= GitUploadPack::pktLine($line . "\n");
        }
        return $output . GitUploadPack::FLUSH_PKT;
    }
    /**
     * receive carries out one push: it reads the commands and the pack
     * file from the posted body, stores the pack's objects, moves each ref
     * whose old value still matches what the repository holds, and builds
     * the report the client asked for with report-status. When the pack
     * cannot be read, no ref moves and every command is reported as
     * failed.
     *
     * @param string $request_body the posted body, with any compression
     *      already undone
     * @return string the report as pkt-lines: "unpack ok" or "unpack" and
     *      the error, then "ok <ref>" or "ng <ref> <reason>" for each
     *      command
     */
    public function receive($request_body)
    {
        list($commands, $pack) = $this->parseCommands($request_body);
        $unpack_status = "ok";
        try {
            if ($pack !== "") {
                $this->storePack($pack);
            }
        } catch (\Exception $e) {
            $unpack_status = $e->getMessage();
        }
        $report = GitUploadPack::pktLine("unpack " . $unpack_status . "\n");
        foreach ($commands as $command) {
            $result = ($unpack_status == "ok") ?
                $this->updateRef($command) : "unpacker error";
            $report .= GitUploadPack::pktLine(($result == "") ?
                "ok " . $command["ref"] . "\n" :
                "ng " . $command["ref"] . " " . $result . "\n");
        }
        return $report . GitUploadPack::FLUSH_PKT;
    }
    /**
     * parseCommands reads the command lines at the start of a push's body,
     * each "<old name> <new name> <ref>", the first also carrying the
     * client's capabilities after a NUL byte, up to the flush line
     * "0000". What follows the flush is the pack file.
     *
     * @param string $request_body the posted body
     * @return array [list of commands, each ["old" =>, "new" =>, "ref" =>],
     *      the pack file's bytes, or "" when there is none]
     */
    public function parseCommands($request_body)
    {
        $commands = [];
        $offset = 0;
        $total = strlen($request_body);
        $digits = GitUploadPack::PKT_LENGTH_DIGITS;
        while ($offset + $digits <= $total) {
            $length_text = substr($request_body, $offset, $digits);
            if (!ctype_xdigit($length_text)) {
                break;
            }
            $length = hexdec($length_text);
            $offset += ($length == 0) ? $digits : 0;
            if ($length == 0) {
                break;
            }
            if ($length < $digits || $offset + $length > $total) {
                break;
            }
            $line = rtrim(substr($request_body, $offset + $digits,
                $length - $digits), "\n");
            $offset += $length;
            $line = explode("\0", $line, 2)[0];
            $parts = explode(" ", $line);
            if (count($parts) == 3) {
                $commands[] = ["old" => $parts[0], "new" => $parts[1],
                    "ref" => $parts[2]];
            }
        }
        return [$commands, (string)substr($request_body, $offset)];
    }
    /**
     * storePack checks a pack file's checksum and writes each object in it
     * to the repository as a loose object, the object compressed in its own
     * file under objects/ in a folder named for the first two hexadecimal
     * digits of its name. An object stored as differences from another is
     * first rebuilt with GitDelta::apply from its base, which is read back
     * from the repository: written there earlier in this pack, or there
     * before the push.
     *
     * @param string $pack the pack file's bytes
     */
    private function storePack($pack)
    {
        $length = strlen($pack);
        if ($length < self::PACK_HEADER_LENGTH + self::PACK_TRAILER_LENGTH ||
            substr($pack, 0, strlen(self::PACK_SIGNATURE)) !==
            self::PACK_SIGNATURE) {
            throw new \Exception("bad pack header");
        }
        $body_length = $length - self::PACK_TRAILER_LENGTH;
        /* the checksum is taken a step at a time, so a large pack is not
           copied whole to hash it */
        $hash = hash_init("sha1");
        for ($pos = 0; $pos < $body_length; $pos += self::HASH_STEP) {
            hash_update($hash, substr($pack, $pos,
                min(self::HASH_STEP, $body_length - $pos)));
        }
        if (hash_final($hash, true) !== substr($pack, $body_length)) {
            throw new \Exception("pack checksum mismatch");
        }
        $count = unpack("N", substr($pack, 8, 4))[1];
        $offset = self::PACK_HEADER_LENGTH;
        for ($i = 0; $i < $count; $i++) {
            if ($offset >= $body_length) {
                throw new \Exception("pack ends early");
            }
            $offset = $this->readPackObject($pack, $offset);
        }
    }
    /**
     * readPackObject reads the object that starts at one place in a pack:
     * its header, giving its type and size, then for a delta the base it
     * is built from, then its compressed body. The rebuilt object is
     * written to the repository with writeLooseObject, and its name is kept
     * by its place for later ofs-deltas.
     *
     * @param string $pack the pack file's bytes
     * @param int $offset where the object starts
     * @return int where the next object starts
     */
    private function readPackObject($pack, $offset)
    {
        $start = $offset;
        $byte = ord($pack[$offset++]);
        $type = ($byte >> self::TYPE_SHIFT) & self::TYPE_MASK;
        $size = $byte & self::FIRST_SIZE_MASK;
        $shift = self::FIRST_SIZE_BITS;
        while ($byte & self::CONTINUATION_FLAG) {
            $byte = ord($pack[$offset++]);
            $size |= ($byte & self::LOW_SEVEN_BITS) << $shift;
            $shift += self::MORE_SIZE_BITS;
        }
        $base = null;
        if ($type == self::TYPE_OFS_DELTA) {
            $byte = ord($pack[$offset++]);
            $distance = $byte & self::LOW_SEVEN_BITS;
            while ($byte & self::CONTINUATION_FLAG) {
                $byte = ord($pack[$offset++]);
                $distance = (($distance + 1) << self::MORE_SIZE_BITS) |
                    ($byte & self::LOW_SEVEN_BITS);
            }
            $base_name = $this->objects_by_offset[$start - $distance] ?? "";
            if ($base_name === "") {
                throw new \Exception("delta base missing");
            }
            $base = $this->repository->readObject($base_name);
        } else if ($type == self::TYPE_REF_DELTA) {
            $base = $this->repository->readObject(bin2hex(substr($pack,
                $offset, self::RAW_NAME_LENGTH)));
            $offset += self::RAW_NAME_LENGTH;
        } else if (!isset(self::TYPE_NAMES[$type])) {
            throw new \Exception("unknown object type");
        }
        list($data, $offset) = $this->inflateAt($pack, $offset, $size);
        if ($base === null) {
            $object_type = self::TYPE_NAMES[$type];
        } else {
            $object_type = $base["type"];
            $data = GitDelta::apply($base["body"], $data);
            $base = null;
        }
        $name = sha1($object_type . " " . strlen($data) . "\0" . $data);
        $this->writeLooseObject($name, $object_type, $data);
        $this->objects_by_offset[$start] = $name;
        return $offset;
    }
    /**
     * inflateAt decompresses one object's body in a pack, which git
     * compresses with zlib (the compression format git uses for its
     * objects). A pack does not say how many compressed bytes an object takes,
     * so the bytes are given to the decompressor a step at a time until
     * it reports the stream has ended, and inflate_get_read_len then says
     * how many it used.
     *
     * @param string $pack the pack file's bytes
     * @param int $offset where the compressed body starts
     * @param int $size how many bytes the body has once decompressed
     * @return array [the decompressed body, where the next object starts]
     */
    private function inflateAt($pack, $offset, $size)
    {
        $context = inflate_init(ZLIB_ENCODING_DEFLATE);
        $data = "";
        $position = $offset;
        $total = strlen($pack);
        while (inflate_get_status($context) != ZLIB_STREAM_END) {
            if ($position >= $total) {
                throw new \Exception("pack ends inside an object");
            }
            $piece = @inflate_add($context,
                substr($pack, $position, self::INFLATE_STEP));
            if ($piece === false) {
                throw new \Exception("pack object will not inflate");
            }
            $data .= $piece;
            $position += self::INFLATE_STEP;
        }
        if (strlen($data) != $size) {
            throw new \Exception("pack object has the wrong size");
        }
        return [$data, $offset + inflate_get_read_len($context)];
    }
    /**
     * writeLooseObject stores one object as git's loose object file,
     * objects/<first two hex digits>/<other 38>, holding the header
     * "<type> <length>" and a NUL byte before the body, compressed with
     * zlib (the compression format git uses for its objects).
     * An object already in the repository as a loose file is left as it
     * is, since its name fixes its content.
     *
     * @param string $name the object's name, the SHA-1 of header and body
     * @param string $type commit, tree, blob or tag
     * @param string $body the object's content
     */
    private function writeLooseObject($name, $type, $body)
    {
        $folder = $this->repository->repo_path . "/objects/" .
            substr($name, 0, 2);
        $path = $folder . "/" . substr($name, 2);
        if (file_exists($path)) {
            return;
        }
        if (!is_dir($folder) && !mkdir($folder, 0777, true) &&
            !is_dir($folder)) {
            throw new \Exception("cannot make object folder");
        }
        $temp_path = $path . ".tmp" . getmypid();
        $compressed = gzcompress($type . " " . strlen($body) . "\0" . $body);
        if (file_put_contents($temp_path, $compressed) === false ||
            !rename($temp_path, $path)) {
            @unlink($temp_path);
            throw new \Exception("cannot write object");
        }
    }
    /**
     * updateRef carries out one command of a push. The ref must be under
     * refs/, its current value must be the command's old value (the zero
     * name meaning it must not exist yet), and a new value other than the
     * zero name must be an object the repository now holds. The ref is
     * then written as its own file, or deleted for a new value of the zero
     * name, and taken out of packed-refs if it was listed there.
     *
     * @param array $command ["old" =>, "new" =>, "ref" =>] from
     *      parseCommands
     * @return string "" when the ref moved, otherwise the reason it did not
     */
    private function updateRef($command)
    {
        $ref = $command["ref"];
        if (!preg_match('/^refs\/[A-Za-z0-9._\/\-]+$/', $ref) ||
            str_contains($ref, "..") || str_ends_with($ref, ".lock")) {
            return "funny refname";
        }
        $refs = $this->repository->allRefs();
        $current = $refs[$ref] ?? self::ZERO_NAME;
        if ($current !== $command["old"]) {
            return "fetch first";
        }
        $path = $this->repository->repo_path . "/" . $ref;
        if ($command["new"] === self::ZERO_NAME) {
            @unlink($path);
            $this->removePackedRef($ref);
            return "";
        }
        try {
            $this->repository->readObject($command["new"]);
        } catch (\Exception $e) {
            return "missing necessary objects";
        }
        if (!is_dir(dirname($path)) && !mkdir(dirname($path), 0777, true)) {
            return "cannot make ref folder";
        }
        if (file_put_contents($path, $command["new"] . "\n") === false) {
            return "cannot write ref";
        }
        $this->removePackedRef($ref);
        return "";
    }
    /**
     * removePackedRef takes one ref's line out of the repository's
     * packed-refs file, the single file where git may keep many refs, so
     * that a ref deleted or rewritten as its own file is not still read
     * from there. A tag's peeled line, which starts with ^ and follows its
     * tag, goes with it.
     *
     * @param string $ref the full ref name, such as refs/heads/main
     */
    private function removePackedRef($ref)
    {
        $packed_path = $this->repository->repo_path . "/packed-refs";
        if (!is_file($packed_path)) {
            return;
        }
        $kept = [];
        $dropping = false;
        foreach (file($packed_path) as $line) {
            if ($dropping && str_starts_with($line, "^")) {
                continue;
            }
            $dropping = (rtrim(explode(" ", rtrim($line, "\n"), 2)[1] ?? "")
                === $ref);
            if (!$dropping) {
                $kept[] = $line;
            }
        }
        file_put_contents($packed_path, implode("", $kept));
    }
}