<?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;

/**
 * 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 "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");
    }
    /**
     * Checks that a pack whose object cannot be read raises a failure
     * naming that object rather than finishing. What reaches the client is
     * held back by the component, which writes the whole reply to a file
     * and sends it only once building has finished, so a failure here
     * becomes an error the client is told about rather than a pack it can
     * never finish.
     */
    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\WORK_DIRECTORY .
            "/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");
    }
    /**
     * Checks that reading pkt-lines stops where the lines stop rather than
     * carrying 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, 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");
    }
    /**
     * 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)];
    }
}
