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

/**
 * Reads objects that Git has collected into packfiles, the compact form
 * objects take after a repository is repacked. A packfile is a single file
 * of compressed objects paired with an index file that says where each
 * object lives inside it. This class finds an object by name through the
 * index, reads it out of the packfile, and where the object was stored as a
 * difference against another object (a "delta") rebuilds the whole object
 * from that chain of differences.
 *
 * It is the companion to GitRepository: that class reads loose objects and
 * refs, and hands off to this class when an object is not present as a loose
 * file. Like GitRepository it only ever reads, never writes or executes the
 * repository, and it works against open file handles rather than loading a
 * whole packfile into memory, so a large repository does not become a large
 * memory cost. Every step is bounded: objects decompress under a fixed size
 * limit, delta chains are followed only to a fixed depth, and offsets read
 * from the files are checked before they are used, so a corrupt or hostile
 * pack cannot exhaust memory or loop forever.
 *
 * @author Chris Pollett
 */
class GitPackReader
{
    /**
     * Absolute path to the bare repository directory
     * @var string
     */
    public $repo_path;
    /**
     * The GitRepository this reader belongs to, used to fetch the base of a
     * delta that refers to its base by object name rather than by position
     * @var object
     */
    public $repository;
    /**
     * Open packfile handles keyed by packfile path, so a packfile is opened
     * once and reused across many object reads
     * @var array
     */
    private $pack_handles = [];
    /**
     * Parsed index summaries keyed by index path, each holding the data
     * needed to look an object name up quickly
     * @var array
     */
    private $index_info = [];
    /**
     * Object type numbers as they appear in a packfile header
     */
    const TYPE_COMMIT = 1;
    const TYPE_TREE = 2;
    const TYPE_BLOB = 3;
    const TYPE_TAG = 4;
    const TYPE_OFS_DELTA = 6;
    const TYPE_REF_DELTA = 7;
    /**
     * Map from packfile type number to the object kind name
     */
    const TYPE_NAMES = [self::TYPE_COMMIT => "commit",
        self::TYPE_TREE => "tree", self::TYPE_BLOB => "blob",
        self::TYPE_TAG => "tag"];
    /**
     * Bytes occupied by the index header and its 256-entry fanout table,
     * after which the sorted object names begin
     */
    const FANOUT_END = 8 + 256 * 4;
    /**
     * Deepest chain of deltas this reader will follow before giving up.
     * Each step of a chain holds its own difference while the version it
     * was built against is read below it, so the memory a read needs
     * grows with the chain. Git itself writes chains no deeper than this,
     * and a thousand steps of a large file was enough to exhaust two
     * gigabytes while serving one pull.
     */
    const MAX_DELTA_DEPTH = 50;
    /**
     * Number of compressed bytes read from a packfile per decompression step
     */
    const INFLATE_CHUNK = 65536;
    /**
     * Sets up a packfile reader for the given bare repository.
     *
     * @param string $repo_path path to the bare repository directory
     * @param object $repository the GitRepository that owns this reader,
     *     used to resolve a delta base given only its object name
     */
    public function __construct($repo_path, $repository)
    {
        $this->repo_path = rtrim($repo_path, "/");
        $this->repository = $repository;
    }
    /**
     * Reads a single object out of whichever packfile contains it, given its
     * object name, and returns its kind and raw contents. Returns null when
     * the object is in none of the repository's packfiles.
     *
     * @param string $sha hex object name to read
     * @return array|null ["type" => kind name, "body" => raw bytes], or null
     *     if no packfile holds the object
     */
    public function readObject($sha)
    {
        $raw_sha = @hex2bin($sha);
        if ($raw_sha === false || strlen($raw_sha) != 20) {
            throw new \Exception("git_bad_object_name");
        }
        foreach ($this->packPairs() as $pair) {
            $offset = $this->findOffset($pair["index"], $raw_sha);
            if ($offset !== null) {
                return $this->readAtOffset($pair["pack"], $offset, 0);
            }
        }
        return null;
    }
    /**
     * Lists the packfiles in the repository as index and packfile path
     * pairs. Only packfiles that have a matching index are returned, since
     * an object cannot be located without its index.
     *
     * @return array list of ["index" => idx path, "pack" => pack path]
     */
    private function packPairs()
    {
        $pairs = [];
        $pack_dir = $this->repo_path . "/objects/pack";
        if (!is_dir($pack_dir)) {
            return $pairs;
        }
        foreach (glob($pack_dir . "/pack-*.idx") as $index_path) {
            $pack_path = substr($index_path, 0, -4) . ".pack";
            if (is_file($pack_path)) {
                $pairs[] = ["index" => $index_path, "pack" => $pack_path];
            }
        }
        return $pairs;
    }
    /**
     * Finds the position of an object inside a packfile by looking its name
     * up in the packfile's index, or returns null if the index does not list
     * it. The index stores object names in sorted order with a small lookup
     * table on the first name byte, so the search goes straight to a narrow
     * range and then bisects it.
     *
     * @param string $index_path path to the packfile index
     * @param string $raw_sha 20 byte object name to look up
     * @return int|null byte position of the object in the packfile, or null
     */
    private function findOffset($index_path, $raw_sha)
    {
        $info = $this->indexInfo($index_path);
        $first_byte = ord($raw_sha[0]);
        $low = ($first_byte == 0) ? 0 : $info["fanout"][$first_byte - 1];
        $high = $info["fanout"][$first_byte];
        $names = $info["contents"];
        $names_start = self::FANOUT_END;
        while ($low < $high) {
            $mid = intdiv($low + $high, 2);
            $name = substr($names, $names_start + $mid * 20, 20);
            if ($name === $raw_sha) {
                return $this->offsetAt($info, $mid);
            }
            if ($name < $raw_sha) {
                $low = $mid + 1;
            } else {
                $high = $mid;
            }
        }
        return null;
    }
    /**
     * Reads the stored byte position of the object at a given rank within a
     * packfile index. Positions too large for the main four-byte table are
     * held in a separate eight-byte table, which this follows when needed.
     *
     * @param array $info parsed index summary
     * @param int $rank position of the object within the sorted names
     * @return int byte position of the object in the packfile
     */
    private function offsetAt($info, $rank)
    {
        $small = unpack("N", substr($info["contents"],
            $info["offsets_start"] + $rank * 4, 4))[1];
        if (($small & 0x80000000) == 0) {
            return $small;
        }
        $large_rank = $small & 0x7fffffff;
        return unpack("J", substr($info["contents"],
            $info["large_start"] + $large_rank * 8, 8))[1];
    }
    /**
     * Loads and remembers the parts of a packfile index needed for lookups:
     * the fanout table, the whole index contents, the object count, and
     * where the names, positions, and large-position tables begin. The index
     * is far smaller than its packfile, so it is read in full once and kept.
     *
     * @param string $index_path path to the packfile index
     * @return array parsed index summary
     */
    private function indexInfo($index_path)
    {
        if (isset($this->index_info[$index_path])) {
            return $this->index_info[$index_path];
        }
        $contents = (string)file_get_contents($index_path);
        if (substr($contents, 0, 8) !== "\377tOc\0\0\0\2") {
            throw new \Exception("git_unsupported_index");
        }
        $fanout = array_values(unpack("N256",
            substr($contents, 8, 256 * 4)));
        $count = $fanout[255];
        $names_start = self::FANOUT_END;
        $offsets_start = $names_start + $count * 20 + $count * 4;
        $large_start = $offsets_start + $count * 4;
        $info = ["contents" => $contents, "fanout" => $fanout,
            "count" => $count, "offsets_start" => $offsets_start,
            "large_start" => $large_start];
        $this->index_info[$index_path] = $info;
        return $info;
    }
    /**
     * Says whether a position in a packfile looks like the start of an
     * object at all. A size beyond what any object may be has two very
     * different causes: an object that really is that big, or a read
     * standing somewhere that is not an object. The kind recorded in the
     * first byte tells them apart, since only seven kinds exist and two of
     * those are never written, and a position past the end of the file
     * settles it outright.
     *
     * @param string $pack_path path to the packfile
     * @param int $offset byte position being read
     * @return string what was found there, in words
     */
    private function describeOffset($pack_path, $offset)
    {
        $size = filesize($pack_path);
        if ($size === false) {
            return "packsize=unknown";
        }
        if ($offset >= $size) {
            return "packsize=" . $size . " verdict=past-the-end";
        }
        $handle = $this->packHandle($pack_path);
        $held = ftell($handle);
        if (fseek($handle, $offset) != 0) {
            return "packsize=" . $size . " verdict=unreachable";
        }
        $first = ord($this->readByte($handle));
        fseek($handle, $held);
        $kind = ($first >> 4) & 0x07;
        $known = [1 => "commit", 2 => "tree", 3 => "blob", 4 => "tag",
            6 => "delta-by-position", 7 => "delta-by-name"];
        if (!isset($known[$kind])) {
            return "packsize=" . $size . " kind=" . $kind .
                " verdict=not-an-object-header";
        }
        return "packsize=" . $size . " kind=" . $known[$kind] .
            " verdict=looks-like-an-object";
    }
    /**
     * Reads the object stored at a given position in a packfile and returns
     * its kind and raw contents, rebuilding it from its base when it was
     * stored as a delta. The depth count rises with each base followed so a
     * chain of deltas cannot be followed without limit.
     *
     * @param string $pack_path path to the packfile
     * @param int $offset byte position of the object in the packfile
     * @param int $depth how many delta bases have been followed so far
     * @return array ["type" => kind name, "body" => raw bytes]
     */
    private function readAtOffset($pack_path, $offset, $depth)
    {
        if ($depth > self::MAX_DELTA_DEPTH) {
            throw new \Exception("git_delta_too_deep");
        }
        $handle = $this->packHandle($pack_path);
        if ($offset < 0 || fseek($handle, $offset) != 0) {
            throw new \Exception("git_bad_pack_offset");
        }
        $first = ord($this->readByte($handle));
        $type = ($first >> 4) & 0x07;
        $size = $first & 0x0f;
        $shift = 4;
        while ($first & 0x80) {
            $first = ord($this->readByte($handle));
            $size |= ($first & 0x7f) << $shift;
            $shift += 7;
            if ($size > GitRepository::MAX_OBJECT_SIZE) {
                /* The size is read from the packfile itself, so a size
                   beyond what any object may be means the read is not
                   standing where an object begins. Saying where it was
                   standing turns a bare complaint into something a
                   person can follow. */
                throw new \Exception("git_object_too_large size=" . $size .
                    " offset=" . $offset . " pack=" . basename($pack_path) .
                    " " . $this->describeOffset($pack_path, $offset));
            }
        }
        if ($type == self::TYPE_OFS_DELTA) {
            /* The version below is read before this step's difference is
               unpacked, so a chain holds one difference at a time rather
               than one for every step still waiting. */
            $base_offset = $offset - $this->readBaseDistance($handle);
            $delta_at = ftell($handle);
            $base = $this->readAtOffset($pack_path, $base_offset, $depth + 1);
            if (fseek($handle, $delta_at) != 0) {
                throw new \Exception("git_bad_pack_offset");
            }
            $delta = $this->inflateExactly($handle, $size);
            $rebuilt = GitDelta::apply($base["body"], $delta);
            $delta = null;
            return ["type" => $base["type"], "body" => $rebuilt];
        }
        if ($type == self::TYPE_REF_DELTA) {
            $base_sha = bin2hex($this->readBytes($handle, 20));
            $delta = $this->inflateExactly($handle, $size);
            $base = $this->repository->readObject($base_sha);
            if ($base === null) {
                throw new \Exception("git_delta_base_missing");
            }
            return ["type" => $base["type"],
                "body" => GitDelta::apply($base["body"], $delta)];
        }
        if (!isset(self::TYPE_NAMES[$type])) {
            throw new \Exception("git_unknown_object_type");
        }
        return ["type" => self::TYPE_NAMES[$type],
            "body" => $this->inflateExactly($handle, $size)];
    }
    /**
     * Reads the distance back to a delta's base for a delta that names its
     * base by position. The distance is stored in a compact form spread over
     * one or more bytes, which this reassembles.
     *
     * @param resource $handle open packfile handle positioned at the distance
     * @return int how far back from the delta the base object sits
     */
    private function readBaseDistance($handle)
    {
        $byte = ord($this->readByte($handle));
        $distance = $byte & 0x7f;
        while ($byte & 0x80) {
            $byte = ord($this->readByte($handle));
            $distance = (($distance + 1) << 7) | ($byte & 0x7f);
        }
        return $distance;
    }
    /**
     * Decompresses exactly the given number of bytes of object data from the
     * current position in a packfile, stopping with an error if it would run
     * past the object size limit or cannot produce the expected amount.
     *
     * @param resource $handle open packfile handle at the compressed data
     * @param int $size number of bytes the object decompresses to
     * @return string the decompressed object data
     */
    private function inflateExactly($handle, $size)
    {
        if ($size > GitRepository::MAX_OBJECT_SIZE) {
            throw new \Exception("git_object_too_large");
        }
        $context = inflate_init(ZLIB_ENCODING_DEFLATE);
        if ($context === false) {
            throw new \Exception("git_object_inflate_failed");
        }
        $out = "";
        while (strlen($out) < $size) {
            $chunk = fread($handle, self::INFLATE_CHUNK);
            if ($chunk === false || $chunk === "") {
                break;
            }
            $piece = inflate_add($context, $chunk);
            if ($piece === false) {
                throw new \Exception("git_object_inflate_failed");
            }
            $out .= $piece;
            if (strlen($out) > GitRepository::MAX_OBJECT_SIZE) {
                throw new \Exception("git_object_too_large");
            }
        }
        if (strlen($out) !== $size) {
            throw new \Exception("git_object_corrupt");
        }
        return $out;
    }
    /**
     * Returns the open handle for a packfile, opening it on first use and
     * reusing it afterwards.
     *
     * @param string $pack_path path to the packfile
     * @return resource the open file handle
     */
    private function packHandle($pack_path)
    {
        if (empty($this->pack_handles[$pack_path])) {
            $handle = fopen($pack_path, "r");
            if ($handle === false) {
                throw new \Exception("git_pack_open_failed");
            }
            $this->pack_handles[$pack_path] = $handle;
        }
        return $this->pack_handles[$pack_path];
    }
    /**
     * Reads a single byte from a packfile handle, treating an unexpected end
     * of file as a corrupt packfile.
     *
     * @param resource $handle open packfile handle
     * @return string the one byte that was read
     */
    private function readByte($handle)
    {
        return $this->readBytes($handle, 1);
    }
    /**
     * Reads an exact number of bytes from a packfile handle, treating a short
     * read as a corrupt packfile.
     *
     * @param resource $handle open packfile handle
     * @param int $count number of bytes to read
     * @return string the bytes that were read
     */
    private function readBytes($handle, $count)
    {
        $data = fread($handle, $count);
        if ($data === false || strlen($data) != $count) {
            throw new \Exception("git_pack_truncated");
        }
        return $data;
    }
}
