<?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\library\version_control;
/**
* GitReceivePack takes in a push over the "smart" side of Git's HTTP
* protocol, in pure PHP with no git program involved. A client first reads
* the list of branches and tags from advertiseRefs. It then posts a list
* of commands, each saying which branch or tag should move from which
* commit to which, followed by a pack file holding the objects the server
* lacks. receive stores each object of the pack as its own compressed
* file in the repository's objects folder, moves the refs, and answers
* with a report saying which commands worked. A ref is git's name for a
* branch or tag, kept as a file holding the object name it points at.
*
* Unlike the older "dumb" push, which sends one WebDAV request (the
* protocol for writing files on a web server) per file, a smart push is
* started by the same git program that handles sign-in, so a person who
* types their user name and code at the prompt, or keeps them with a
* credential helper, can push.
*
* @author Chris Pollett
*/
class GitReceivePack
{
/**
* RECEIVE_PACK_SERVICE the name a client gives, in the service query
* argument and in the path it posts to, when it wants to push
*/
const RECEIVE_PACK_SERVICE = "git-receive-pack";
/**
* CAPABILITIES the things this server tells a pushing client it can do:
* report-status asks for a report of how each command went,
* delete-refs lets a push delete a branch, and ofs-delta lets the pack
* store an object as its differences from an object earlier in the
* same pack
*/
const CAPABILITIES = "report-status delete-refs ofs-delta";
/**
* ZERO_NAME the object name a command uses for "no object": as its old
* value when it makes a new ref, and as its new value when it deletes
* one
*/
const ZERO_NAME = "0000000000000000000000000000000000000000";
/**
* PACK_SIGNATURE the four bytes a pack file starts with
*/
const PACK_SIGNATURE = "PACK";
/**
* PACK_HEADER_LENGTH the length of a pack file's header: its
* signature, its version and its object count, four bytes each
*/
const PACK_HEADER_LENGTH = 12;
/**
* PACK_TRAILER_LENGTH the length of the SHA-1 checksum, the 20-byte
* hash of every byte before it, that ends a pack file
*/
const PACK_TRAILER_LENGTH = 20;
/**
* TYPE_NAMES the kind of object each type number in a pack stands for,
* for the four kinds stored whole
*/
const TYPE_NAMES = [1 => "commit", 2 => "tree", 3 => "blob",
4 => "tag"];
/**
* TYPE_OFS_DELTA the type number of an object stored as its
* differences from an object at an earlier place in the same pack
*/
const TYPE_OFS_DELTA = 6;
/**
* TYPE_REF_DELTA the type number of an object stored as its
* differences from an object named by its 20-byte object name
*/
const TYPE_REF_DELTA = 7;
/**
* RAW_NAME_LENGTH the length of an object name as 20 raw bytes, as a
* ref-delta gives its base
*/
const RAW_NAME_LENGTH = 20;
/**
* LOW_SEVEN_BITS the bits of a byte that carry a number in git's
* variable-length encodings, the eighth bit saying another byte
* follows
*/
const LOW_SEVEN_BITS = 0x7f;
/**
* CONTINUATION_FLAG the eighth bit of a byte in a variable-length
* number, set when another byte follows
*/
const CONTINUATION_FLAG = 0x80;
/**
* FIRST_SIZE_MASK the bits of an object header's first byte that hold
* the low four bits of the object's size
*/
const FIRST_SIZE_MASK = 0x0f;
/**
* TYPE_SHIFT how far the type number sits from the bottom of an
* object header's first byte
*/
const TYPE_SHIFT = 4;
/**
* TYPE_MASK the bits of the shifted first header byte that hold the
* type number
*/
const TYPE_MASK = 0x07;
/**
* FIRST_SIZE_BITS how many bits of an object's size the first header
* byte holds
*/
const FIRST_SIZE_BITS = 4;
/**
* MORE_SIZE_BITS how many bits of a number each further byte of a
* variable-length encoding holds
*/
const MORE_SIZE_BITS = 7;
/**
* INFLATE_STEP how many compressed bytes are given to the
* decompressor at a time while reading one object
*/
const INFLATE_STEP = 8192;
/**
* HASH_STEP how many bytes of a pack are given to the SHA-1 hash at a
* time while its checksum is checked
*/
const HASH_STEP = 1048576;
/**
* $repository reads the refs and objects of the bare repository a push
* goes to, and gives the folder new objects and refs are written in
* @var GitRepository
*/
private $repository;
/**
* $objects_by_offset maps the place in the pack where each object read
* so far starts to its object name, so an ofs-delta can find its base.
* Only names are kept; each object is written to the repository as it
* is read and its base is read back from there, so a large push does
* not hold every object in memory at once.
* @var array
*/
private $objects_by_offset = [];
/**
* __construct sets up a receiver for pushes to one repository.
*
* @param GitRepository $repository the bare repository being pushed to
*/
public function __construct($repository)
{
$this->repository = $repository;
}
/**
* advertiseRefs builds the reply a pushing client reads first, over a
* GET of info/refs with service=git-receive-pack. It names the service,
* then lists each ref with the object it points at, the first line
* carrying the server's CAPABILITIES. A repository with no refs yet
* sends one line naming no object, as git does, so the client still
* learns the capabilities.
*
* @return string the advertisement as pkt-lines, git's lines each led
* by its length in four hexadecimal digits
*/
public function advertiseRefs()
{
$output = GitUploadPack::pktLine("# service=" .
self::RECEIVE_PACK_SERVICE . "\n") . GitUploadPack::FLUSH_PKT;
$refs = $this->repository->allRefs();
if (empty($refs)) {
$refs = ["capabilities^{}" => self::ZERO_NAME];
}
$first = true;
foreach ($refs as $name => $object_name) {
$line = $object_name . " " . $name;
if ($first) {
$line .= "\0" . self::CAPABILITIES;
$first = false;
}
$output .= GitUploadPack::pktLine($line . "\n");
}
return $output . GitUploadPack::FLUSH_PKT;
}
/**
* receive carries out one push: it reads the commands and the pack
* file from the posted body, stores the pack's objects, moves each ref
* whose old value still matches what the repository holds, and builds
* the report the client asked for with report-status. When the pack
* cannot be read, no ref moves and every command is reported as
* failed.
*
* @param string $request_body the posted body, with any compression
* already undone
* @return string the report as pkt-lines: "unpack ok" or "unpack" and
* the error, then "ok <ref>" or "ng <ref> <reason>" for each
* command
*/
public function receive($request_body)
{
list($commands, $pack) = $this->parseCommands($request_body);
$unpack_status = "ok";
try {
if ($pack !== "") {
$this->storePack($pack);
}
} catch (\Exception $e) {
$unpack_status = $e->getMessage();
}
$report = GitUploadPack::pktLine("unpack " . $unpack_status . "\n");
foreach ($commands as $command) {
$result = ($unpack_status == "ok") ?
$this->updateRef($command) : "unpacker error";
$report .= GitUploadPack::pktLine(($result == "") ?
"ok " . $command["ref"] . "\n" :
"ng " . $command["ref"] . " " . $result . "\n");
}
return $report . GitUploadPack::FLUSH_PKT;
}
/**
* parseCommands reads the command lines at the start of a push's body,
* each "<old name> <new name> <ref>", the first also carrying the
* client's capabilities after a NUL byte, up to the flush line
* "0000". What follows the flush is the pack file.
*
* @param string $request_body the posted body
* @return array [list of commands, each ["old" =>, "new" =>, "ref" =>],
* the pack file's bytes, or "" when there is none]
*/
public function parseCommands($request_body)
{
$commands = [];
$offset = 0;
$total = strlen($request_body);
$digits = GitUploadPack::PKT_LENGTH_DIGITS;
while ($offset + $digits <= $total) {
$length_text = substr($request_body, $offset, $digits);
if (!ctype_xdigit($length_text)) {
break;
}
$length = hexdec($length_text);
$offset += ($length == 0) ? $digits : 0;
if ($length == 0) {
break;
}
if ($length < $digits || $offset + $length > $total) {
break;
}
$line = rtrim(substr($request_body, $offset + $digits,
$length - $digits), "\n");
$offset += $length;
$line = explode("\0", $line, 2)[0];
$parts = explode(" ", $line);
if (count($parts) == 3) {
$commands[] = ["old" => $parts[0], "new" => $parts[1],
"ref" => $parts[2]];
}
}
return [$commands, (string)substr($request_body, $offset)];
}
/**
* storePack checks a pack file's checksum and writes each object in it
* to the repository as a loose object, the object compressed in its own
* file under objects/ in a folder named for the first two hexadecimal
* digits of its name. An object stored as differences from another is
* first rebuilt with GitDelta::apply from its base, which is read back
* from the repository: written there earlier in this pack, or there
* before the push.
*
* @param string $pack the pack file's bytes
*/
private function storePack($pack)
{
$length = strlen($pack);
if ($length < self::PACK_HEADER_LENGTH + self::PACK_TRAILER_LENGTH ||
substr($pack, 0, strlen(self::PACK_SIGNATURE)) !==
self::PACK_SIGNATURE) {
throw new \Exception("bad pack header");
}
$body_length = $length - self::PACK_TRAILER_LENGTH;
/* the checksum is taken a step at a time, so a large pack is not
copied whole to hash it */
$hash = hash_init("sha1");
for ($pos = 0; $pos < $body_length; $pos += self::HASH_STEP) {
hash_update($hash, substr($pack, $pos,
min(self::HASH_STEP, $body_length - $pos)));
}
if (hash_final($hash, true) !== substr($pack, $body_length)) {
throw new \Exception("pack checksum mismatch");
}
$count = unpack("N", substr($pack, 8, 4))[1];
$offset = self::PACK_HEADER_LENGTH;
for ($i = 0; $i < $count; $i++) {
if ($offset >= $body_length) {
throw new \Exception("pack ends early");
}
$offset = $this->readPackObject($pack, $offset);
}
}
/**
* readPackObject reads the object that starts at one place in a pack:
* its header, giving its type and size, then for a delta the base it
* is built from, then its compressed body. The rebuilt object is
* written to the repository with writeLooseObject, and its name is kept
* by its place for later ofs-deltas.
*
* @param string $pack the pack file's bytes
* @param int $offset where the object starts
* @return int where the next object starts
*/
private function readPackObject($pack, $offset)
{
$start = $offset;
$byte = ord($pack[$offset++]);
$type = ($byte >> self::TYPE_SHIFT) & self::TYPE_MASK;
$size = $byte & self::FIRST_SIZE_MASK;
$shift = self::FIRST_SIZE_BITS;
while ($byte & self::CONTINUATION_FLAG) {
$byte = ord($pack[$offset++]);
$size |= ($byte & self::LOW_SEVEN_BITS) << $shift;
$shift += self::MORE_SIZE_BITS;
}
$base = null;
if ($type == self::TYPE_OFS_DELTA) {
$byte = ord($pack[$offset++]);
$distance = $byte & self::LOW_SEVEN_BITS;
while ($byte & self::CONTINUATION_FLAG) {
$byte = ord($pack[$offset++]);
$distance = (($distance + 1) << self::MORE_SIZE_BITS) |
($byte & self::LOW_SEVEN_BITS);
}
$base_name = $this->objects_by_offset[$start - $distance] ?? "";
if ($base_name === "") {
throw new \Exception("delta base missing");
}
$base = $this->repository->readObject($base_name);
} else if ($type == self::TYPE_REF_DELTA) {
$base = $this->repository->readObject(bin2hex(substr($pack,
$offset, self::RAW_NAME_LENGTH)));
$offset += self::RAW_NAME_LENGTH;
} else if (!isset(self::TYPE_NAMES[$type])) {
throw new \Exception("unknown object type");
}
list($data, $offset) = $this->inflateAt($pack, $offset, $size);
if ($base === null) {
$object_type = self::TYPE_NAMES[$type];
} else {
$object_type = $base["type"];
$data = GitDelta::apply($base["body"], $data);
$base = null;
}
$name = sha1($object_type . " " . strlen($data) . "\0" . $data);
$this->writeLooseObject($name, $object_type, $data);
$this->objects_by_offset[$start] = $name;
return $offset;
}
/**
* inflateAt decompresses one object's body in a pack, which git
* compresses with zlib (the compression format git uses for its
* objects). A pack does not say how many compressed bytes an object takes,
* so the bytes are given to the decompressor a step at a time until
* it reports the stream has ended, and inflate_get_read_len then says
* how many it used.
*
* @param string $pack the pack file's bytes
* @param int $offset where the compressed body starts
* @param int $size how many bytes the body has once decompressed
* @return array [the decompressed body, where the next object starts]
*/
private function inflateAt($pack, $offset, $size)
{
$context = inflate_init(ZLIB_ENCODING_DEFLATE);
$data = "";
$position = $offset;
$total = strlen($pack);
while (inflate_get_status($context) != ZLIB_STREAM_END) {
if ($position >= $total) {
throw new \Exception("pack ends inside an object");
}
$piece = @inflate_add($context,
substr($pack, $position, self::INFLATE_STEP));
if ($piece === false) {
throw new \Exception("pack object will not inflate");
}
$data .= $piece;
$position += self::INFLATE_STEP;
}
if (strlen($data) != $size) {
throw new \Exception("pack object has the wrong size");
}
return [$data, $offset + inflate_get_read_len($context)];
}
/**
* writeLooseObject stores one object as git's loose object file,
* objects/<first two hex digits>/<other 38>, holding the header
* "<type> <length>" and a NUL byte before the body, compressed with
* zlib (the compression format git uses for its objects).
* An object already in the repository as a loose file is left as it
* is, since its name fixes its content.
*
* @param string $name the object's name, the SHA-1 of header and body
* @param string $type commit, tree, blob or tag
* @param string $body the object's content
*/
private function writeLooseObject($name, $type, $body)
{
$folder = $this->repository->repo_path . "/objects/" .
substr($name, 0, 2);
$path = $folder . "/" . substr($name, 2);
if (file_exists($path)) {
return;
}
if (!is_dir($folder) && !mkdir($folder, 0777, true) &&
!is_dir($folder)) {
throw new \Exception("cannot make object folder");
}
$temp_path = $path . ".tmp" . getmypid();
$compressed = gzcompress($type . " " . strlen($body) . "\0" . $body);
if (file_put_contents($temp_path, $compressed) === false ||
!rename($temp_path, $path)) {
@unlink($temp_path);
throw new \Exception("cannot write object");
}
}
/**
* updateRef carries out one command of a push. The ref must be under
* refs/, its current value must be the command's old value (the zero
* name meaning it must not exist yet), and a new value other than the
* zero name must be an object the repository now holds. The ref is
* then written as its own file, or deleted for a new value of the zero
* name, and taken out of packed-refs if it was listed there.
*
* @param array $command ["old" =>, "new" =>, "ref" =>] from
* parseCommands
* @return string "" when the ref moved, otherwise the reason it did not
*/
private function updateRef($command)
{
$ref = $command["ref"];
if (!preg_match('/^refs\/[A-Za-z0-9._\/\-]+$/', $ref) ||
str_contains($ref, "..") || str_ends_with($ref, ".lock")) {
return "funny refname";
}
$refs = $this->repository->allRefs();
$current = $refs[$ref] ?? self::ZERO_NAME;
if ($current !== $command["old"]) {
return "fetch first";
}
$path = $this->repository->repo_path . "/" . $ref;
if ($command["new"] === self::ZERO_NAME) {
@unlink($path);
$this->removePackedRef($ref);
return "";
}
try {
$this->repository->readObject($command["new"]);
} catch (\Exception $e) {
return "missing necessary objects";
}
if (!is_dir(dirname($path)) && !mkdir(dirname($path), 0777, true)) {
return "cannot make ref folder";
}
if (file_put_contents($path, $command["new"] . "\n") === false) {
return "cannot write ref";
}
$this->removePackedRef($ref);
return "";
}
/**
* removePackedRef takes one ref's line out of the repository's
* packed-refs file, the single file where git may keep many refs, so
* that a ref deleted or rewritten as its own file is not still read
* from there. A tag's peeled line, which starts with ^ and follows its
* tag, goes with it.
*
* @param string $ref the full ref name, such as refs/heads/main
*/
private function removePackedRef($ref)
{
$packed_path = $this->repository->repo_path . "/packed-refs";
if (!is_file($packed_path)) {
return;
}
$kept = [];
$dropping = false;
foreach (file($packed_path) as $line) {
if ($dropping && str_starts_with($line, "^")) {
continue;
}
$dropping = (rtrim(explode(" ", rtrim($line, "\n"), 2)[1] ?? "")
=== $ref);
if (!$dropping) {
$kept[] = $line;
}
}
file_put_contents($packed_path, implode("", $kept));
}
}