<?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 — three commits
* each recording one file — 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;
}
}