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\GitRepository;
use seekquarry\yioop\library\GitUploadPack;
use seekquarry\yioop\library\UnitTest;

/**
 * Checks that GitUploadPack speaks the smart side of Git's HTTP protocol
 * correctly: that it frames and unframes pkt-lines, reads a client's upload
 * request, advertises the repository's refs, walks history to the requested
 * depth (reporting the right shallow boundary), and writes a packfile whose
 * header, object count, contents, and trailing checksum are all well-formed.
 *
 * 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
{
    /**
     * Absolute path to the throwaway bare repository the test builds
     * @var string
     */
    public $repo_path;
    /**
     * The GitRepository reading the built repository
     * @var object
     */
    public $repository;
    /**
     * Object names of the three commits built, oldest first
     * @var array
     */
    public $commits;
    /**
     * Object name the built repository's single branch points at
     * @var string
     */
    public $head_commit;

    /**
     * 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\WORK_DIRECTORY . "/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);
    }
    /**
     * Removes the throwaway repository the test built.
     */
    public function tearDown()
    {
        $this->removeRepo();
    }
    /**
     * Deletes the test repository folder if it is present.
     */
    private function removeRepo()
    {
        if (is_dir($this->repo_path)) {
            $this->deleteTree($this->repo_path);
        }
    }
    /**
     * Recursively deletes a folder and everything in it.
     *
     * @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);
    }
    /**
     * Stores a git object the loose way: prepend the type and size header,
     * name the object by the checksum of that, and write the compressed
     * bytes to the two-level objects folder.
     *
     * @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;
    }
    /**
     * Writes a tree object listing the given files, each a regular file
     * pointing at its blob, in the name order git keeps entries in.
     *
     * @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);
    }
    /**
     * Writes a commit object for a tree, with the given parents and message
     * and a fixed author and committer, so its object name is stable.
     *
     * @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> 1700000000 +0000";
        $body .= "author " . $who . "\n";
        $body .= "committer " . $who . "\n\n" . $message . "\n";
        return $this->writeObject("commit", $body);
    }
    /**
     * A pkt-line's length counts its own four length digits, and decoding
     * the framing returns the original data with the flush marker shown as
     * a break.
     */
    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");
    }
    /**
     * The upload request parser pulls out the wanted object names and the
     * requested depth, and ignores capabilities on the first want line.
     */
    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");
    }
    /**
     * The advertisement names the service, offers the shallow capability,
     * points HEAD at the built branch, and lists the branch ref.
     */
    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");
    }
    /**
     * A depth-one walk keeps only the newest commit, packs its commit,
     * tree, and blobs, and reports that commit as the shallow boundary
     * because its parent is left out.
     */
    public function walkDepthOneTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $body = GitUploadPack::pktLine("want " . $this->head_commit .
            "\n") . GitUploadPack::pktLine("deepen 1\n") .
            GitUploadPack::FLUSH_PKT . GitUploadPack::pktLine("done\n");
        $reply = $upload_pack->uploadPack($body);
        $this->assertTrue(strpos($reply,
            "shallow " . $this->head_commit) !== false,
            "the newest commit is reported as the shallow boundary");
        $this->assertTrue(strpos($reply,
            "shallow " . $this->commits[0]) === false,
            "an older commit is not in the reply at depth one");
        $pack = $this->extractPack($reply);
        $count = $this->packObjectCount($pack);
        $this->assertEqual($count, 5,
            "depth one packs the newest commit, its tree, and its blobs");
    }
    /**
     * A walk with no depth follows history to its start, packing every
     * commit, tree, and blob, and reports no shallow boundary.
     */
    public function walkFullHistoryTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $body = GitUploadPack::pktLine("want " . $this->head_commit .
            "\n") . GitUploadPack::FLUSH_PKT .
            GitUploadPack::pktLine("done\n");
        $reply = $upload_pack->uploadPack($body);
        $this->assertTrue(strpos($reply, "shallow ") === false,
            "a full clone reports no shallow boundary");
        $pack = $this->extractPack($reply);
        $count = $this->packObjectCount($pack);
        $this->assertEqual($count, 9,
            "full history packs three commits, three trees, three blobs");
    }
    /**
     * A "have" line names an object the client already holds; the parser
     * collects those alongside the wants.
     */
    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");
    }
    /**
     * When the client says it already has an older commit, the walk stops at
     * that commit and everything that commit records &mdash; its folders and
     * their files &mdash; is left out of the pack too, so a fetch past it
     * sends only what the client is actually missing.
     */
    public function walkStopsAtHaveTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $body = GitUploadPack::pktLine("want " . $this->head_commit .
            "\n") . GitUploadPack::pktLine("have " . $this->commits[0] .
            "\n") . GitUploadPack::FLUSH_PKT .
            GitUploadPack::pktLine("done\n");
        $reply = $upload_pack->uploadPack($body);
        $pack = $this->extractPack($reply);
        $count = $this->packObjectCount($pack);
        $this->assertEqual($count, 6,
            "a fetch past a have leaves out that commit, its tree, and " .
            "the file it added");
    }
    /**
     * A round in which nothing the client offers is recognized is answered
     * with a bare "NAK" and no packfile, so the client offers older commits
     * next rather than reading pack bytes where a pkt-line should start.
     */
    public function negotiationRoundWithoutCommonTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $unknown = str_repeat("c", 40);
        $body = GitUploadPack::pktLine("want " . $this->head_commit .
            "\n") . GitUploadPack::FLUSH_PKT .
            GitUploadPack::pktLine("have " . $unknown . "\n");
        $reply = $upload_pack->uploadPack($body);
        $this->assertTrue(strpos($reply, "PACK") === false,
            "an unsettled round carries no packfile");
        $this->assertEqual($reply, GitUploadPack::pktLine("NAK\n"),
            "an unsettled round is answered with a bare NAK");
    }
    /**
     * When the client offers a commit the repository also holds, the round
     * names it as common and says the server is ready, which is what stops
     * a client with a long history offering ever older commits.
     */
    public function negotiationRoundWithCommonTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $body = GitUploadPack::pktLine("want " . $this->head_commit .
            "\n") . GitUploadPack::FLUSH_PKT .
            GitUploadPack::pktLine("have " . $this->commits[0] . "\n");
        $reply = $upload_pack->uploadPack($body);
        $this->assertTrue(strpos($reply, "PACK") === false,
            "a settled round still carries no packfile");
        $this->assertTrue(strpos($reply,
            "ACK " . $this->commits[0] . " common") !== false,
            "the recognized commit is named as held in common");
        $this->assertTrue(strpos($reply,
            "ACK " . $this->commits[0] . " ready") !== false,
            "the round says the server is ready to send");
    }
    /**
     * Once the client says it is done, the reply acknowledges the newest
     * commit the two share and the packfile follows it.
     */
    public function doneRoundAcknowledgesCommonTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $body = GitUploadPack::pktLine("want " . $this->head_commit .
            "\n") . GitUploadPack::pktLine("have " . $this->commits[0] .
            "\n") . GitUploadPack::FLUSH_PKT .
            GitUploadPack::pktLine("done\n");
        $reply = $upload_pack->uploadPack($body);
        $this->assertTrue(strncmp($reply,
            GitUploadPack::pktLine("ACK " . $this->commits[0] . "\n"),
            strlen(GitUploadPack::pktLine("ACK " . $this->commits[0] .
            "\n"))) === 0,
            "the done round opens with a final acknowledgment");
        $this->assertTrue(strpos($reply, "PACK") !== false,
            "the packfile follows the acknowledgment");
    }
    /**
     * The packfile a reply carries is well-formed: it starts with the pack
     * signature and version two, its stated object count matches what was
     * asked for, and its trailing checksum matches the bytes before it.
     */
    public function packIsWellFormedTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $body = GitUploadPack::pktLine("want " . $this->head_commit .
            "\n") . GitUploadPack::pktLine("deepen 1\n") .
            GitUploadPack::FLUSH_PKT . GitUploadPack::pktLine("done\n");
        $reply = $upload_pack->uploadPack($body);
        $pack = $this->extractPack($reply);
        $this->assertEqual(substr($pack, 0, 4), "PACK",
            "packfile starts with the pack signature");
        $version = unpack("N", substr($pack, 4, 4))[1];
        $this->assertEqual($version, 2, "packfile is format version two");
        $trailer = substr($pack, -20);
        $checked = sha1(substr($pack, 0, strlen($pack) - 20), true);
        $this->assertEqual($trailer, $checked,
            "packfile checksum matches its contents");
    }
    /**
     * The first object in the pack inflates back to the exact bytes the
     * repository holds for it, showing the writer stored it faithfully.
     */
    public function packObjectInflatesTestCase()
    {
        $upload_pack = new GitUploadPack($this->repository);
        $body = GitUploadPack::pktLine("want " . $this->head_commit .
            "\n") . GitUploadPack::pktLine("deepen 1\n") .
            GitUploadPack::FLUSH_PKT . GitUploadPack::pktLine("done\n");
        $reply = $upload_pack->uploadPack($body);
        $pack = $this->extractPack($reply);
        $first = $this->firstPackObject($pack);
        $original = $this->repository->readObject($this->head_commit);
        $this->assertEqual($first["type"],
            GitUploadPack::OBJECT_TYPE_COMMIT,
            "first packed object is the wanted commit");
        $this->assertEqual($first["body"], $original["body"],
            "packed commit inflates to the repository's bytes");
    }
    /**
     * Pulls the packfile out of an upload-pack reply: everything from the
     * "PACK" signature onward, after the shallow lines and acknowledgment.
     *
     * @param string $reply the full upload-pack reply
     * @return string the packfile bytes
     */
    private function extractPack($reply)
    {
        $start = strpos($reply, "PACK");
        return substr($reply, $start);
    }
    /**
     * Reads the object count out of a packfile's twelve-byte header.
     *
     * @param string $pack the packfile bytes
     * @return int the number of objects the header claims
     */
    private function packObjectCount($pack)
    {
        return unpack("N", substr($pack, 8, 4))[1];
    }
    /**
     * Reads the first object out of a packfile, undoing the type-and-size
     * header and inflating the body, so a test can compare it to the source.
     *
     * @param string $pack the packfile bytes
     * @return array ["type" => the pack type code, "body" => the inflated
     *      object bytes]
     */
    private function firstPackObject($pack)
    {
        $offset = 12;
        $first = ord($pack[$offset]);
        $type = ($first >> 4) & 0x7;
        $offset++;
        while ($first & 0x80) {
            $first = ord($pack[$offset]);
            $offset++;
        }
        $compressed = substr($pack, $offset,
            strlen($pack) - 20 - $offset);
        return ["type" => $type, "body" => gzuncompress($compressed)];
    }
}