SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / GitUploadPackTest.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\tests;

use seekquarry\yioop\configs as C;
use seekquarry\yioop\library\version_control\GitRepository;
use seekquarry\yioop\library\version_control\GitUploadPack;
use seekquarry\yioop\library\UnitTest;

/**
 * GitUploadPackTest checks that GitUploadPack speaks the smart side of
 * Git's HTTP protocol correctly. Its cases check that GitUploadPack frames
 * and reads pkt-lines (git's length-prefixed records), reads a client's
 * upload request, and advertises the repository's branches and tags. They
 * also check that it walks history as far as the request allows, naming
 * the right shallow commits, and writes a packfile (the one file a set of
 * git objects travels in) that git can read.
 *
 * The test set-up builds a tiny real repository by hand &mdash; three commits
 * each recording one file &mdash; by writing loose objects the way Git stores
 * them, so the walk and the pack writer run against a GitRepository reading
 * real objects rather than mock-ups.
 *
 * @author Chris Pollett
 */
class GitUploadPackTest extends UnitTest
{
    /**
     * COMMIT_TIME is the time, in seconds since 1970, writeCommit puts in
     * every commit it writes, so that a commit's object name is the same on
     * every run
     */
    const COMMIT_TIME = 1700000000;
    /**
     * PACK_COUNT_AT is where in a packfile (the one file a set of git
     * objects travels in) the count of its objects begins: after the four
     * letters "PACK" and the four bytes of its version
     */
    const PACK_COUNT_AT = 8;
    /**
     * PACK_HEADER_LENGTH is how many bytes come before the first object
     * of a packfile (the one file a set of git objects travels in): the
     * four letters "PACK", the version and the object count
     */
    const PACK_HEADER_LENGTH = 12;
    /**
     * TYPE_BITS keeps the three bits of the first byte of a packed object's
     * header, once shifted down, that give the object's kind
     */
    const TYPE_BITS = 0x7;
    /**
     * $repo_path is the folder of the throwaway bare repository setUp
     * builds and tearDown removes
     * @var string
     */
    public $repo_path;
    /**
     * $repository is the GitRepository that reads the repository setUp
     * builds, which each case hands to the GitUploadPack it tests
     * @var object
     */
    public $repository;
    /**
     * $commits holds the object names of the three commits setUp builds,
     * oldest first
     * @var array
     */
    public $commits;
    /**
     * $head_commit is the object name of the newest commit setUp builds,
     * which the repository's one branch points at
     * @var string
     */
    public $head_commit;

    /**
     * setUp builds a small bare repository on disk with a three-commit
     * history, one file added per commit, so the object walk and pack
     * writer have real objects to work over.
     */
    public function setUp()
    {
        $this->repo_path = C\TEST_DIR . "/test_files/test_git_upload_pack.git";
        $this->removeRepo();
        mkdir($this->repo_path . "/objects", 0777, true);
        mkdir($this->repo_path . "/refs/heads", 0777, true);
        $this->commits = [];
        $parents = [];
        $prior_tree_entries = [];
        for ($i = 1; $i <= 3; $i++) {
            $blob_sha = $this->writeObject("blob", "contents of file $i\n");
            $prior_tree_entries["file$i.txt"] = $blob_sha;
            $tree_sha = $this->writeTree($prior_tree_entries);
            $commit_sha = $this->writeCommit($tree_sha, $parents,
                "commit number $i");
            $this->commits[] = $commit_sha;
            $parents = [$commit_sha];
        }
        $this->head_commit = $this->commits[count($this->commits) - 1];
        file_put_contents($this->repo_path . "/HEAD",
            "ref: refs/heads/main\n");
        file_put_contents($this->repo_path . "/refs/heads/main",
            $this->head_commit . "\n");
        $this->repository = new GitRepository($this->repo_path);
    }
    /**
     * tearDown runs after each case and has removeRepo take away the
     * repository setUp built, so no case sees another's objects.
     */
    public function tearDown()
    {
        $this->removeRepo();
    }
    /**
     * removeRepo takes away the folder at $repo_path, with everything in
     * it, when such a folder is there. setUp calls it before building and
     * tearDown after each case.
     */
    private function removeRepo()
    {
        if (is_dir($this->repo_path)) {
            $this->deleteTree($this->repo_path);
        }
    }
    /**
     * deleteTree empties a folder, going into each folder inside it in
     * turn, and then removes the folder itself. removeRepo uses it on the
     * test repository.
     *
     * @param string $path folder to delete
     */
    private function deleteTree($path)
    {
        foreach (scandir($path) as $name) {
            if ($name == "." || $name == "..") {
                continue;
            }
            $child = $path . "/" . $name;
            if (is_dir($child)) {
                $this->deleteTree($child);
            } else {
                unlink($child);
            }
        }
        rmdir($path);
    }
    /**
     * writeObject puts one git object into the test repository as a loose
     * file, as git itself stores a new object. The object's kind and size
     * go in front of its contents, the SHA-1 checksum (a 20-byte summary)
     * of the whole becomes its name, and the compressed bytes go in a
     * folder named by the first two characters of that name.
     *
     * @param string $type the object type word
     * @param string $body the object's uncompressed contents
     * @return string the object's name
     */
    private function writeObject($type, $body)
    {
        $store = $type . " " . strlen($body) . "\0" . $body;
        $sha = sha1($store);
        $dir = $this->repo_path . "/objects/" . substr($sha, 0, 2);
        if (!is_dir($dir)) {
            mkdir($dir, 0777, true);
        }
        file_put_contents($dir . "/" . substr($sha, 2),
            gzcompress($store));
        return $sha;
    }
    /**
     * writeTree makes the tree, git's word for a folder, that holds the
     * given files, each an ordinary file whose contents are the named blob
     * (git's word for a file's contents). Entries are sorted by name, the
     * order git keeps them in, and the tree is stored with writeObject.
     *
     * @param array $entries map from file name to blob object name
     * @return string the tree's object name
     */
    private function writeTree($entries)
    {
        ksort($entries);
        $body = "";
        foreach ($entries as $name => $blob_sha) {
            $body .= "100644 " . $name . "\0" . hex2bin($blob_sha);
        }
        return $this->writeObject("tree", $body);
    }
    /**
     * writeCommit records a commit of the given folder in the test
     * repository, naming its parents and message. The author, committer
     * and time are fixed, so the commit's object name is the same on every
     * run.
     *
     * @param string $tree_sha object name of the commit's tree
     * @param array $parents object names of the parent commits
     * @param string $message the commit message
     * @return string the commit's object name
     */
    private function writeCommit($tree_sha, $parents, $message)
    {
        $body = "tree " . $tree_sha . "\n";
        foreach ($parents as $parent_sha) {
            $body .= "parent " . $parent_sha . "\n";
        }
        $who = "Test Person <test@example.com> " . self::COMMIT_TIME .
            " +0000";
        $body .= "author " . $who . "\n";
        $body .= "committer " . $who . "\n\n" . $message . "\n";
        return $this->writeObject("commit", $body);
    }
    /**
     * pktLineRoundTripTestCase frames data with pktLine and reads it back
     * with decodePktLines. The length a pkt-line gives has to count its own
     * four length digits, and reading has to give back the data, with the
     * flush marker shown as false.
     */
    public function pktLineRoundTripTestCase()
    {
        $framed = GitUploadPack::pktLine("hello\n");
        $this->assertEqual(substr($framed, 0, 4), "000a",
            "pkt-line length counts the four length digits");
        $stream = GitUploadPack::pktLine("want a\n") .
            GitUploadPack::FLUSH_PKT . GitUploadPack::pktLine("done\n");
        $decoded = GitUploadPack::decodePktLines($stream);
        $this->assertEqual($decoded[0], "want a",
            "first line decodes and loses its newline");
        $this->assertTrue($decoded[1] === false,
            "flush marker decodes to false");
        $this->assertEqual($decoded[2], "done",
            "line after the flush decodes");
    }
    /**
     * parseUploadRequestTestCase hands parseUploadRequest a request with
     * two wants and a depth, and a capability it does not use on the first
     * want line. Both wanted object names and the depth have to be read,
     * and the capability must not be taken for part of a name.
     */
    public function parseUploadRequestTestCase()
    {
        $want = str_repeat("a", 40);
        $other = str_repeat("b", 40);
        $body = GitUploadPack::pktLine("want $want side-band-64k\n") .
            GitUploadPack::pktLine("want $other\n") .
            GitUploadPack::pktLine("deepen 1\n") .
            GitUploadPack::FLUSH_PKT .
            GitUploadPack::pktLine("done\n");
        $upload_pack = new GitUploadPack($this->repository);
        $request = $upload_pack->parseUploadRequest($body);
        $this->assertEqual(count($request["wants"]), 2,
            "both wanted object names are read");
        $this->assertTrue(in_array($want, $request["wants"]),
            "the object name from the first want is kept");
        $this->assertEqual($request["depth"], 1,
            "the requested depth is read");
    }
    /**
     * advertiseRefsTestCase reads the advertisement advertiseRefs builds
     * for the test repository. It has to name the service, offer the
     * shallow capability, point HEAD at the built branch, and list that
     * branch at its commit.
     */
    public function advertiseRefsTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $advertisement = $upload_pack->advertiseRefs();
        $lines = GitUploadPack::decodePktLines($advertisement);
        $this->assertEqual($lines[0], "# service=git-upload-pack",
            "advertisement begins by naming the service");
        $this->assertTrue(strpos($advertisement, "shallow") !== false,
            "advertisement offers the shallow capability");
        $this->assertTrue(strpos($advertisement,
            "symref=HEAD:refs/heads/main") !== false,
            "advertisement points HEAD at the default branch");
        $this->assertTrue(strpos($advertisement,
            $this->head_commit . " refs/heads/main") !== false,
            "advertisement lists the branch at its commit");
    }
    /**
     * askingForACommitByNameTestCase checks that the advertisement offers
     * the two capabilities a client needs before it will ask for an object
     * by its name, and that such a request is served: a client that knows a
     * commit name but sees no branch or tag pointing at it can still fetch
     * it and its history.
     */
    public function askingForACommitByNameTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $advertisement = $upload_pack->advertiseRefs();
        $this->assertTrue(strpos($advertisement,
            "allow-tip-sha1-in-want") !== false,
            "advertisement lets a client ask for a tip by name");
        $this->assertTrue(strpos($advertisement,
            "allow-reachable-sha1-in-want") !== false,
            "advertisement lets a client ask for any reachable object");
        $older_commit = $this->commits[0];
        $this->assertTrue($older_commit != $this->head_commit,
            "the older commit is not the one the branch points at");
        $body = GitUploadPack::pktLine("want " . $older_commit . "\n") .
            GitUploadPack::FLUSH_PKT .
            GitUploadPack::pktLine("done\n");
        $chunks = $upload_pack->uploadPackChunks($body);
        $served = "";
        foreach ($chunks as $chunk) {
            $served .= $chunk;
        }
        $this->assertTrue(strlen($served) > 0,
            "a want naming an unadvertised commit is served a pack");
        $this->assertTrue(strpos($served, "PACK") !== false,
            "and what comes back holds a packfile");
    }
    /**
     * tagIsNoBaseForACommitTestCase asks for an annotated tag of the head
     * commit along with the commit. Tags and commits are packed side by
     * side, and an object written as a difference from another takes that
     * object's type. So a commit built on the tag reaches git as a tag,
     * and git stops the clone with "invalid tag". No object in the pack
     * may be built on the tag.
     */
    public function tagIsNoBaseForACommitTestCase()
    {
        $who = "Test Person <test@example.com> 1700000000 +0000";
        $tag_sha = $this->writeObject("tag", "object " .
            $this->head_commit . "\ntype commit\ntag v1\ntagger " . $who .
            "\n\ncommit number 3\n");
        $body = GitUploadPack::pktLine("want " . $tag_sha . "\n") .
            GitUploadPack::pktLine("want " . $this->head_commit . "\n") .
            GitUploadPack::FLUSH_PKT . GitUploadPack::pktLine("done\n");
        $upload_pack = new GitUploadPack($this->repository);
        $served = "";
        foreach ($upload_pack->uploadPackChunks($body) as $chunk) {
            $served .= $chunk;
        }
        $bases = $this->deltaBases(substr($served, strpos($served, "PACK")));
        $this->assertTrue(!empty($bases),
            "some objects are written as differences");
        $this->assertTrue(!in_array($tag_sha, $bases),
            "no object is written as a difference from the tag");
    }
    /**
     * parseHavesTestCase sends parseUploadRequest a request with one have
     * line, which names an object the client already holds. That object
     * name has to come back in the request's list of held objects.
     */
    public function parseHavesTestCase()
    {
        $want = str_repeat("a", 40);
        $have = str_repeat("b", 40);
        $body = GitUploadPack::pktLine("want $want\n") .
            GitUploadPack::pktLine("have $have\n") .
            GitUploadPack::FLUSH_PKT .
            GitUploadPack::pktLine("done\n");
        $upload_pack = new GitUploadPack($this->repository);
        $request = $upload_pack->parseUploadRequest($body);
        $this->assertEqual(count($request["haves"]), 1,
            "the have object name is read");
        $this->assertTrue(in_array($have, $request["haves"]),
            "the have object name is the one the client sent");
    }
    /**
     * unreadableObjectSendsNothingTestCase asks packChunks for an object
     * an empty repository cannot read. Building has to stop at that object
     * with a failure saying it could not be read, rather than finish a
     * pack that leaves the object out. GitComponent logs such a failure,
     * and git, given a pack that ends early, stops with an error rather
     * than keep a repository with a missing object.
     */
    public function unreadableObjectSendsNothingTestCase()
    {
        /* A repository with nothing in it, so the object asked for
           cannot be read whatever name is used. */
        $empty = new GitRepository(C\TEST_DIR .
            "/test_files/test_git_upload_pack_empty.git");
        $server = new GitUploadPack($empty);
        $names = ["0000000000000000000000000000000000000000"];
        $sent = "";
        $said = "";
        try {
            $maker = new \ReflectionMethod($server, "packChunks");
            foreach ($maker->invoke($server, $names) as $chunk) {
                $sent .= $chunk;
            }
        } catch (\Throwable $problem) {
            $said = $problem->getMessage();
        }
        $this->assertTrue(strpos($said, "git_object") === 0,
            "the failure says an object could not be read");
        $this->assertTrue(strpos($sent, "PACK") === 0 && strlen($sent) < 32,
            "building stops at the object rather than finishing the pack");
    }
    /**
     * readingStopsWhereTheLinesStopTestCase has decodePktLines read lines
     * followed by other bytes. Reading has to stop where the lines stop
     * rather than carry on into whatever follows them. A push sends its
     * commands as lines and then a pack; reading the pack's bytes as a line
     * length produced a number made from whichever characters happened to
     * be hexadecimal (base sixteen), put the parse out of step, and on a
     * live site filled the log with warnings while the reply it built was
     * wrong.
     */
    public function readingStopsWhereTheLinesStopTestCase()
    {
        $lines = GitUploadPack::pktLine("want abc\n") .
            GitUploadPack::FLUSH_PKT;
        $pack = "PACK" . pack("N", 2) . pack("N", 1) . "\x9c\x03\xff\x01";
        $warned = "";
        set_error_handler(function ($number, $message) use (&$warned) {
            $warned .= $message;
            return true;
        });
        $read = GitUploadPack::decodePktLines($lines . $pack);
        restore_error_handler();
        $this->assertEqual(count($read), 2,
            "the two lines are read and the pack is left alone");
        $this->assertEqual($read[0], "want abc",
            "the line's content comes back whole");
        $this->assertTrue($read[1] === false,
            "the flush that ends the lines is reported");
        $this->assertEqual($warned, "",
            "nothing is handed to a hexadecimal reading that is not one");
    }
    /**
     * shallowCloneIsDeepenedTestCase stands for a client that cloned the
     * built three-commit history with depth 1, so it holds the head commit
     * and names it as where its history stops. It then fetches once with
     * --depth 2, which asks for depth 2 from the head, and once with
     * --deepen 1, which asks for one commit more beyond the head through
     * the deepen-relative capability. git asks in two rounds: the first
     * lists no held commits, and the second lists them and says done.
     * Both replies have to name the middle commit as the new shallow
     * commit and give the head back as unshallow, and the second has to
     * pack the middle commit and its folder, the two objects the client
     * lacks. Before, the client's holding of the head was taken to cover
     * all history under it, and the pack came back empty.
     */
    public function shallowCloneIsDeepenedTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $this->assertTrue(strpos($upload_pack->advertiseRefs(),
            "deepen-relative") !== false,
            "the advertisement offers deepen-relative, which --deepen needs");
        $head = $this->head_commit;
        $middle = $this->commits[1];
        $requests = ["--depth 2" =>
            GitUploadPack::pktLine("want $head thin-pack\n") .
            GitUploadPack::pktLine("shallow $head\n") .
            GitUploadPack::pktLine("deepen 2\n"),
            "--deepen 1" =>
            GitUploadPack::pktLine("want $head thin-pack deepen-relative\n") .
            GitUploadPack::pktLine("shallow $head\n") .
            GitUploadPack::pktLine("deepen 1\n")];
        foreach ($requests as $option => $start) {
            $first_round = "";
            foreach ($upload_pack->uploadPackChunks($start .
                GitUploadPack::FLUSH_PKT) as $chunk) {
                $first_round .= $chunk;
            }
            $this->assertEqual(GitUploadPack::decodePktLines($first_round),
                ["shallow $middle", "unshallow $head", false],
                "the first round of a fetch with $option, which lists no " .
                "held commits, already says where the history ends");
            $body = $start . GitUploadPack::FLUSH_PKT .
                GitUploadPack::pktLine("have $head\n") .
                GitUploadPack::pktLine("done\n");
            $reply = "";
            foreach ($upload_pack->uploadPackChunks($body) as $chunk) {
                $reply .= $chunk;
            }
            $lines = GitUploadPack::decodePktLines(substr($reply, 0,
                strpos($reply, "PACK")));
            $this->assertEqual(array_slice($lines, 0, 3),
                ["shallow $middle", "unshallow $head", false],
                "a fetch with $option names the middle commit shallow and " .
                "the head unshallow");
            $this->assertEqual(unpack("N", $reply, strpos($reply, "PACK") +
                self::PACK_COUNT_AT)[1], 2, "a fetch with $option packs " .
                "the middle commit and its folder");
        }
    }
    /**
     * deltaBases reads every object of a packfile (the one file a set of
     * git objects travels in) in turn and lists the object names that its
     * differences are built on. An object written
     * as a difference from another names that other object in the twenty
     * bytes after its header. tagIsNoBaseForACommitTestCase uses the list.
     * Each object's compressed body is inflated only to learn where the
     * next object starts.
     *
     * @param string $pack the packfile bytes
     * @return array object names, in hexadecimal, that differences are
     *      built on
     */
    private function deltaBases($pack)
    {
        $bases = [];
        $offset = self::PACK_HEADER_LENGTH;
        $count = unpack("N", $pack, self::PACK_COUNT_AT)[1];
        for ($i = 0; $i < $count; $i++) {
            $first = ord($pack[$offset]);
            $type = ($first >> GitUploadPack::FIRST_SIZE_BITS) &
                self::TYPE_BITS;
            $offset++;
            while ($first & GitUploadPack::CONTINUATION_FLAG) {
                $first = ord($pack[$offset]);
                $offset++;
            }
            if ($type == GitUploadPack::TYPE_REF_DELTA) {
                $bases[] = bin2hex(substr($pack, $offset,
                    GitRepository::SHA_RAW_LENGTH));
                $offset += GitRepository::SHA_RAW_LENGTH;
            }
            $inflater = inflate_init(ZLIB_ENCODING_DEFLATE);
            inflate_add($inflater, substr($pack, $offset), ZLIB_FINISH);
            $offset += inflate_get_read_len($inflater);
        }
        return $bases;
    }
}