SeekquarrySeekquarry - Yioop Repo - Seekquarry

/ tests / GitReceivePackTest.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\GitReceivePack;
use seekquarry\yioop\library\version_control\GitRepository;
use seekquarry\yioop\library\version_control\GitUploadPack;
use seekquarry\yioop\library\UnitTest;

/**
 * GitReceivePackTest checks that GitReceivePack (the library class that
 * stores what a git client pushes) takes in a push over the smart side of
 * Git's HTTP protocol. The test set-up builds a source
 * repository by hand, writing loose objects the way git stores them: two
 * commits, the second changing a long file a little, so the pack holds an
 * object stored as its differences from another. GitUploadPack packs the
 * source's history, as a pushing client would, and the cases hand that
 * pack to GitReceivePack for an empty target repository.
 *
 * @author Chris Pollett
 */
class GitReceivePackTest extends UnitTest
{
    /**
     * LONG_FILE_LINES how many lines the long test file has, enough that
     * its second version is packed as differences from its first
     */
    const LONG_FILE_LINES = 400;
    /**
     * $source_path the folder of the repository the push comes from
     * @var string
     */
    public $source_path;
    /**
     * $target_path the folder of the repository the push goes to
     * @var string
     */
    public $target_path;
    /**
     * $commits the object names of the source's two commits, oldest first
     * @var array
     */
    public $commits;
    /**
     * $target reads the empty bare repository each case pushes into, so a
     * case can check its refs and objects afterward
     * @var GitRepository
     */
    public $target;
    /**
     * setUp builds the source repository, two commits of a long file and
     * a short one, and an empty bare target repository with initBare.
     */
    public function setUp()
    {
        $this->source_path = C\TEST_DIR .
            "/test_files/test_git_receive_source.git";
        $this->target_path = C\TEST_DIR .
            "/test_files/test_git_receive_target.git";
        $this->tearDown();
        mkdir($this->source_path . "/objects", 0777, true);
        mkdir($this->source_path . "/refs/heads", 0777, true);
        $long_text = "";
        for ($i = 0; $i < self::LONG_FILE_LINES; $i++) {
            $long_text .= "line $i of a file long enough to be a delta\n";
        }
        $this->commits = [];
        $parents = [];
        foreach ([$long_text, $long_text . "one more line\n"] as $number =>
            $text) {
            $tree = $this->writeObject("tree", "100644 long.txt\0" .
                hex2bin($this->writeObject("blob", $text)) .
                "100644 short.txt\0" .
                hex2bin($this->writeObject("blob", "version $number\n")));
            $body = "tree $tree\n";
            foreach ($parents as $parent) {
                $body .= "parent $parent\n";
            }
            $who = "Test Person <test@example.com> 1700000000 +0000";
            $body .= "author $who\ncommitter $who\n\ncommit $number\n";
            $commit = $this->writeObject("commit", $body);
            $this->commits[] = $commit;
            $parents = [$commit];
        }
        file_put_contents($this->source_path . "/HEAD",
            "ref: refs/heads/main\n");
        file_put_contents($this->source_path . "/refs/heads/main",
            $this->commits[1] . "\n");
        mkdir($this->target_path);
        $this->target = new GitRepository($this->target_path);
        $this->target->initBare("main");
    }
    /**
     * tearDown deletes both test repositories.
     */
    public function tearDown()
    {
        foreach ([$this->source_path, $this->target_path] as $path) {
            if (is_dir($path)) {
                $this->deleteTree($path);
            }
        }
    }
    /**
     * deleteTree deletes a folder and everything in it.
     *
     * @param string $path the 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 stores one object in the source repository as git's
     * loose object file: the header "<type> <length>", a NUL byte and the
     * body, zlib compressed, named by the SHA-1 of what is compressed.
     *
     * @param string $type commit, tree or blob
     * @param string $body the object's content
     * @return string the object's name
     */
    private function writeObject($type, $body)
    {
        $store = $type . " " . strlen($body) . "\0" . $body;
        $name = sha1($store);
        $folder = $this->source_path . "/objects/" . substr($name, 0, 2);
        if (!is_dir($folder)) {
            mkdir($folder, 0777, true);
        }
        file_put_contents($folder . "/" . substr($name, 2),
            gzcompress($store));
        return $name;
    }
    /**
     * sourcePack has GitUploadPack pack the source's whole history, as a
     * client builds the pack it pushes, and returns the pack file's bytes.
     *
     * @return string the pack file
     */
    private function sourcePack()
    {
        $upload_pack = new GitUploadPack(new GitRepository(
            $this->source_path));
        $reply = "";
        foreach ($upload_pack->uploadPackChunks(GitUploadPack::pktLine(
            "want " . $this->commits[1] . "\n") . GitUploadPack::FLUSH_PKT .
            GitUploadPack::pktLine("done\n")) as $chunk) {
            $reply .= $chunk;
        }
        return substr($reply, strpos($reply, "PACK"));
    }
    /**
     * pushBody builds the body a pushing client posts: one command line,
     * with the client's capabilities after a NUL byte, a flush, then the
     * pack.
     *
     * @param string $old the ref's value the client expects
     * @param string $new the value the ref should take
     * @param string $pack the pack file, or "" for none
     * @return string the request body
     */
    private function pushBody($old, $new, $pack)
    {
        return GitUploadPack::pktLine("$old $new refs/heads/main\0" .
            "report-status\n") . GitUploadPack::FLUSH_PKT . $pack;
    }
    /**
     * pushIntoEmptyRepositoryTestCase pushes the source's history to the
     * empty target. The report says the pack unpacked and the ref moved,
     * the target's main branch names the newer commit, and every object,
     * including the long file's second version, which the pack holds as
     * its differences from the first, reads back from the target.
     */
    public function pushIntoEmptyRepositoryTestCase()
    {
        $receive_pack = new GitReceivePack($this->target);
        $this->assertTrue(str_contains($receive_pack->advertiseRefs(),
            "capabilities^{}\0report-status"), "an empty repository " .
            "advertises its capabilities on a line naming no object");
        $report = $receive_pack->receive($this->pushBody(
            GitReceivePack::ZERO_NAME, $this->commits[1],
            $this->sourcePack()));
        $this->assertEqual(GitUploadPack::decodePktLines($report),
            ["unpack ok", "ok refs/heads/main", false],
            "the report says the pack unpacked and the ref moved");
        $refs = $this->target->allRefs();
        $this->assertEqual($refs["refs/heads/main"] ?? "",
            $this->commits[1], "main names the pushed commit");
        $tree = $this->target->commit($this->commits[1])["tree"];
        $this->assertTrue(str_ends_with($this->target->blob(
            $this->target->tree($tree)[0]["sha"] ?? "") ?? "",
            "one more line\n"), "the long file's second version reads " .
            "back whole");
    }
    /**
     * staleOldValueIsRefusedTestCase pushes with an old value that is not
     * what the target's ref holds. The report says to fetch first and the
     * ref stays where it was.
     */
    public function staleOldValueIsRefusedTestCase()
    {
        $receive_pack = new GitReceivePack($this->target);
        $pack = $this->sourcePack();
        $receive_pack->receive($this->pushBody(GitReceivePack::ZERO_NAME,
            $this->commits[0], $pack));
        $report = $receive_pack->receive($this->pushBody(
            GitReceivePack::ZERO_NAME, $this->commits[1], ""));
        $this->assertEqual(GitUploadPack::decodePktLines($report),
            ["unpack ok", "ng refs/heads/main fetch first", false],
            "a push from a stale old value is refused");
        $this->assertEqual($this->target->allRefs()["refs/heads/main"] ??
            "", $this->commits[0], "and the ref did not move");
    }
    /**
     * damagedPackMovesNoRefTestCase pushes a pack with one byte changed.
     * Its checksum no longer matches, so the report says it did not
     * unpack, the command fails, and the target still has no main branch.
     */
    public function damagedPackMovesNoRefTestCase()
    {
        $pack = $this->sourcePack();
        $middle = intdiv(strlen($pack), 2);
        $pack[$middle] = chr(ord($pack[$middle]) ^ 1);
        $report = (new GitReceivePack($this->target))->receive(
            $this->pushBody(GitReceivePack::ZERO_NAME, $this->commits[1],
            $pack));
        $this->assertEqual(GitUploadPack::decodePktLines($report),
            ["unpack pack checksum mismatch",
            "ng refs/heads/main unpacker error", false],
            "a damaged pack is reported and its command fails");
        $this->assertTrue(!isset($this->target->allRefs()["refs/heads/main"]),
            "and no branch was made");
    }
}