<?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\library as L;
use seekquarry\yioop\library\UnitTest;
/**
* Unit tests for the GitPackReader library class, which reads objects out
* of a Git packfile. Each test builds a small packfile and its index in PHP
* (no git program needed) and then checks the reader finds objects through
* the index and rebuilds an object that was stored as a difference against
* another (a delta). Reading is done through GitRepository, which is how the
* pack reader is reached in normal use.
*
* @author Chris Pollett
*/
class GitPackReaderTest extends UnitTest
{
/**
* Folder, relative to this test file, holding the repository built for
* a test run
*/
const TEST_DIR = '/test_files/git_pack_reader_test';
/**
* Absolute path of the test repository for the current test
* @var string
*/
public $repo_path;
/**
* Starts each test from an empty, freshly created bare repository.
*/
public function setUp()
{
$this->repo_path = __DIR__ . self::TEST_DIR;
$this->deleteTree($this->repo_path);
$repository = new L\GitRepository($this->repo_path);
$repository->initBare("main");
}
/**
* Removes the test repository created during a test.
*/
public function tearDown()
{
$this->deleteTree($this->repo_path);
}
/**
* Deletes a folder and everything under it.
*
* @param string $path folder to remove
*/
public function deleteTree($path)
{
if (!is_dir($path)) {
return;
}
foreach (scandir($path) as $name) {
if ($name == "." || $name == "..") {
continue;
}
$full = $path . "/" . $name;
if (is_dir($full)) {
$this->deleteTree($full);
} else {
unlink($full);
}
}
rmdir($path);
}
/**
* Encodes a packfile object header giving the object's type and size in
* the variable-length form a packfile uses.
*
* @param int $type packfile type number
* @param int $size uncompressed size of the object data
* @return string the encoded header bytes
*/
public function objectHeader($type, $size)
{
$current = ($type << 4) | ($size & 0x0f);
$size >>= 4;
$header = "";
while ($size > 0) {
$header .= chr($current | 0x80);
$current = $size & 0x7f;
$size >>= 7;
}
return $header . chr($current);
}
/**
* Encodes the distance back to a delta's base in the form a packfile
* uses for a base named by position.
*
* @param int $distance how far back the base object sits
* @return string the encoded distance bytes
*/
public function offsetDistance($distance)
{
$bytes = chr($distance & 0x7f);
$distance >>= 7;
while ($distance > 0) {
$distance -= 1;
$bytes = chr(0x80 | ($distance & 0x7f)) . $bytes;
$distance >>= 7;
}
return $bytes;
}
/**
* Encodes a number in the little-endian base-128 form used for the two
* sizes at the start of a delta.
*
* @param int $number value to encode
* @return string the encoded bytes
*/
public function deltaSize($number)
{
$bytes = "";
do {
$seven = $number & 0x7f;
$number >>= 7;
$bytes .= chr($number > 0 ? ($seven | 0x80) : $seven);
} while ($number > 0);
return $bytes;
}
/**
* Returns the object name (hex SHA-1) a body of a given kind would have.
*
* @param string $type_name object kind name
* @param string $body raw object contents
* @return string the object name
*/
public function objectName($type_name, $body)
{
return sha1($type_name . " " . strlen($body) . "\0" . $body);
}
/**
* Writes a packfile and its index into the test repository given a list
* of already-assembled entries. Each entry supplies the object name, the
* bytes to place in the packfile, and the pack data used for the index.
*
* @param array $entries list of ["sha" => name, "pack" => pack bytes]
*/
public function writePack($entries)
{
$pack = "PACK" . pack("N", 2) . pack("N", count($entries));
$offsets = [];
foreach ($entries as $entry) {
$offsets[$entry["sha"]] = strlen($pack);
$pack .= $entry["pack"];
}
$pack .= hex2bin(sha1($pack));
$names = array_keys($offsets);
sort($names);
$counts = array_fill(0, 256, 0);
foreach ($names as $sha) {
$counts[hexdec(substr($sha, 0, 2))]++;
}
$idx = "\377tOc" . pack("N", 2);
$running = 0;
for ($slot = 0; $slot < 256; $slot++) {
$running += $counts[$slot];
$idx .= pack("N", $running);
}
foreach ($names as $sha) {
$idx .= hex2bin($sha);
}
foreach ($names as $sha) {
$idx .= pack("N", 0);
}
foreach ($names as $sha) {
$idx .= pack("N", $offsets[$sha]);
}
$idx .= hex2bin(sha1($pack)) . hex2bin(sha1($idx));
$base = $this->repo_path . "/objects/pack/pack-" . sha1($pack);
file_put_contents($base . ".pack", $pack);
file_put_contents($base . ".idx", $idx);
}
/**
* Objects stored whole in a packfile are found through the index and
* read back with their correct kind and contents.
*/
public function readWholeObjectsTestCase()
{
$blob = "a blob stored in a pack\n";
$tree_body = "40000 dir\0" . str_repeat("z", 20);
$entries = [
["sha" => $this->objectName("blob", $blob),
"pack" => $this->objectHeader(3, strlen($blob)) .
gzcompress($blob)],
["sha" => $this->objectName("tree", $tree_body),
"pack" => $this->objectHeader(2, strlen($tree_body)) .
gzcompress($tree_body)]];
$this->writePack($entries);
$repository = new L\GitRepository($this->repo_path);
$blob_object = $repository->readObject($entries[0]["sha"]);
$this->assertEqual("blob", $blob_object["type"],
"whole blob has right kind");
$this->assertEqual($blob, $blob_object["body"],
"whole blob has right contents");
$tree_object = $repository->readObject($entries[1]["sha"]);
$this->assertEqual("tree", $tree_object["type"],
"whole tree has right kind");
$this->assertEqual($tree_body, $tree_object["body"],
"whole tree has right contents");
}
/**
* An object stored as a delta against an earlier object in the same
* packfile is rebuilt into the full object when read.
*/
public function resolveOffsetDeltaTestCase()
{
$base = "0123456789";
$added = "ABCDE";
$result = $base . $added;
$base_pack = $this->objectHeader(3, strlen($base)) .
gzcompress($base);
$base_offset = strlen("PACK" . pack("N", 2) . pack("N", 2));
$delta_stream = $this->deltaSize(strlen($base)) .
$this->deltaSize(strlen($result)) .
chr(0x90) . chr(strlen($base)) .
chr(strlen($added)) . $added;
$delta_offset = $base_offset + strlen($base_pack);
$delta_pack = $this->objectHeader(6, strlen($delta_stream)) .
$this->offsetDistance($delta_offset - $base_offset) .
gzcompress($delta_stream);
$entries = [
["sha" => $this->objectName("blob", $base), "pack" => $base_pack],
["sha" => $this->objectName("blob", $result),
"pack" => $delta_pack]];
$this->writePack($entries);
$repository = new L\GitRepository($this->repo_path);
$rebuilt = $repository->readObject($this->objectName("blob",
$result));
$this->assertEqual("blob", $rebuilt["type"],
"rebuilt delta has the base object's kind");
$this->assertEqual($result, $rebuilt["body"],
"delta rebuilt into the full object");
}
}