Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 0 additions & 16 deletions .basedpyright/baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -373,22 +373,6 @@
}
],
"./git/repo/base.py": [
{
"code": "reportReturnType",
"range": {
"startColumn": 15,
"endColumn": 46,
"lineCount": 1
}
},
{
"code": "reportReturnType",
"range": {
"startColumn": 15,
"endColumn": 51,
"lineCount": 1
}
},
{
"code": "reportReturnType",
"range": {
Expand Down
7 changes: 0 additions & 7 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -1,13 +1,6 @@
exclude: ^(?:gitdb|smmap)/

repos:
- repo: https://github.com/codespell-project/codespell
rev: v2.4.3
hooks:
- id: codespell
additional_dependencies: [tomli]
exclude: ^test/fixtures/

- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.5
hooks:
Expand Down
9 changes: 9 additions & 0 deletions doc/source/changes.rst
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@
Changelog
=========

Unreleased
==========

* Deprecate the pure-Python ``GitDB`` object database backend due to security and
performance issues. Selecting it or a subclass through ``odbt`` now emits a
``DeprecationWarning``. Remove ``odbt=GitDB`` to use ``GitCmdObjectDB``, the
existing default, or select ``odbt=GitCmdObjectDB`` explicitly. The ``gitdb``
package remains a dependency for shared types and utilities.

3.2.1
=====

Expand Down
31 changes: 18 additions & 13 deletions doc/source/tutorial.rst
Original file line number Diff line number Diff line change
Expand Up @@ -513,24 +513,29 @@ Object Databases

The type of the database determines certain performance characteristics, such as the quantity of objects that can be read per second, the resource usage when reading large data files, as well as the average memory footprint of your application.

GitDB
=====
The GitDB is a pure-python implementation of the git object database. It is the default database to use in GitPython 0.3. It uses less memory when handling huge files, but will be 2 to 5 times slower when extracting large quantities of small objects from densely packed repositories::
GitCmdObjectDB
==============
``GitCmdObjectDB`` is the default and recommended backend. It reads objects and
resolves abbreviated object IDs through persistent ``git cat-file`` processes::

repo = Repo("path/to/repo", odbt=GitDB)
repo = Repo("path/to/repo")
# Equivalent explicit selection:
repo = Repo("path/to/repo", odbt=GitCmdObjectDB)

GitDB
=====
.. warning::
``GitDB`` may fail or become extremely slow when traversing trees in
repositories with very large commits (thousands of changed files in a
single commit). If you encounter ``RecursionError`` or excessive
slowness during tree traversal, switch to ``GitCmdObjectDB`` instead.
The pure-Python ``GitDB`` backend is deprecated due to security and performance
issues. Its object parsers can exhaust resources or return incorrect object
data when processing untrusted repositories. Do not use it for untrusted data.

Selecting ``odbt=GitDB`` (including a subclass) emits a ``DeprecationWarning``.
To migrate, remove ``odbt=GitDB`` or replace it with ``odbt=GitCmdObjectDB`` when
opening, initializing, or cloning a repository. The deprecated backend remains
available for compatibility; deprecation does not fix its parsing issues.

GitCmdObjectDB
==============
The git command database uses persistent git-cat-file instances to read repository information. These operate very fast under all conditions, but will consume additional memory for the process itself. When extracting large files, memory usage will be much higher than ``GitDB``::

repo = Repo("path/to/repo", odbt=GitCmdObjectDB)
The ``gitdb`` package remains a dependency because GitPython still uses its shared
types and utilities.

Git Command Debugging and Customization
***************************************
Expand Down
25 changes: 15 additions & 10 deletions git/cmd.py
Original file line number Diff line number Diff line change
Expand Up @@ -646,7 +646,8 @@ class Git(metaclass=_GitMeta):
)

# Match Git's leading transport selector, including an empty helper name.
re_unsafe_protocol = re.compile(r"([A-Za-z0-9][A-Za-z0-9+.-]*|)::")
# Git also selects the command-executing ext helper for an ext:// URL.
re_unsafe_protocol = re.compile(r"([A-Za-z0-9][A-Za-z0-9+.-]*|)::|ext://")

unsafe_git_ls_remote_options = [
# This option allows arbitrary command execution in git-ls-remote.
Expand Down Expand Up @@ -946,7 +947,8 @@ def check_unsafe_protocols(cls, url: str) -> None:

Apart from the usual protocols (http, git, ssh), Git allows "remote helpers"
that have the form ``<transport>::<address>``. One of these helpers (``ext::``)
can be used to invoke any arbitrary command.
can be used to invoke any arbitrary command. Git also selects that helper
for ``ext://`` URLs and interprets the URL as a command path.

See:

Expand All @@ -955,11 +957,19 @@ def check_unsafe_protocols(cls, url: str) -> None:
"""
match = cls.re_unsafe_protocol.match(url)
if match:
protocol = match.group(1)
protocol = match.group(0)
raise UnsafeProtocolError(
f"The `{protocol}::` protocol looks suspicious, use `allow_unsafe_protocols=True` to allow it."
f"The `{protocol}` protocol looks suspicious, use `allow_unsafe_protocols=True` to allow it."
)

def _check_unsafe_protocols_in_args(self, args: Sequence[Any], kwargs: Mapping[str, Any]) -> None:
"""Check positional operands and standalone values in rendered command options.

A short flag's split value can become the repository operand before ``--``.
"""
for arg in self._unpack_args(args) + self.transform_kwargs(**kwargs):
self.check_unsafe_protocols(arg)

@classmethod
def _canonicalize_option_name(cls, option: str) -> str:
"""Return the option name used for unsafe-option checks.
Expand Down Expand Up @@ -1146,12 +1156,7 @@ def ls_remote(
candidate_options = self._option_candidates(args, kwargs)
Git.check_unsafe_options(options=candidate_options, unsafe_options=self.unsafe_git_ls_remote_options)
if not allow_unsafe_protocols:
protocol_args = list(args)
if kwargs.get("split_single_char_options", True):
# Split short-option values can become the URL after parsing earlier options.
protocol_args.extend(value for key, value in kwargs.items() if len(key) == 1)
for arg in self._unpack_args(protocol_args):
self.check_unsafe_protocols(arg)
self._check_unsafe_protocols_in_args(args, kwargs)
return self._call_process("ls_remote", *args, **kwargs)

@property
Expand Down
57 changes: 49 additions & 8 deletions git/index/base.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
from io import BytesIO
import os
import os.path as osp
from stat import S_ISLNK
from stat import S_ISLNK, S_ISREG
import subprocess
import sys
import tempfile
Expand All @@ -36,6 +36,7 @@
file_contents_ro,
_is_path_rooted,
_to_relative_path,
_validate_repo_path,
to_native_path_linux,
unbare_repo,
to_bin_sha,
Expand Down Expand Up @@ -476,7 +477,17 @@ def raise_exc(e: Exception) -> NoReturn:
continue
# END glob handling
try:
for root, _dirs, files in os.walk(abs_path, onerror=raise_exc):
for root, dirs, files in os.walk(abs_path, onerror=raise_exc):
for dirname in dirs[:]:
directory = osp.join(root, dirname)
try:
_validate_repo_path(to_native_path_linux(osp.relpath(directory, r)))
except ValueError:
dirs.remove(dirname)
continue
if osp.islink(directory):
dirs.remove(dirname)
yield osp.relpath(directory, r)
for rela_file in files:
# Add relative paths only.
yield osp.join(root.replace(rs, ""), rela_file)
Expand Down Expand Up @@ -717,6 +728,8 @@ def _preprocess_add_items(
else:
raise TypeError("Invalid Type: %r" % item)
# END for each item
for entry in entries:
_validate_repo_path(entry.path)
return paths, entries

def _store_path(self, filepath: PathLike, fprogress: Callable) -> BaseIndexEntry:
Expand All @@ -726,20 +739,43 @@ def _store_path(self, filepath: PathLike, fprogress: Callable) -> BaseIndexEntry
This needs the :func:`~git.index.util.git_working_dir` decorator active!
This must be ensured in the calling code.
"""
st = os.lstat(filepath) # Handles non-symlinks as well.

filepath = self._to_relative_path(filepath)
_validate_repo_path(filepath)
parent = osp.realpath(self.repo.working_dir)
for component in os.fspath(filepath).split("/")[:-1]:
parent = osp.join(parent, component)
if osp.islink(parent) or osp.normcase(osp.realpath(parent)) != osp.normcase(osp.abspath(parent)):
raise ValueError("Cannot stage a path beyond a symbolic link: %r" % filepath)
Comment on lines +744 to +748
st = os.lstat(filepath)
if not S_ISLNK(st.st_mode) and not S_ISREG(st.st_mode):
raise ValueError("Can only stage a regular file or symbolic link: %r" % filepath)

stream_size = st.st_size
if S_ISLNK(st.st_mode):
# readlink is a string, but we need bytes.
target = force_bytes(os.readlink(filepath), encoding=defenc)
stream_size = len(target)

def open_stream() -> BinaryIO:
return BytesIO(force_bytes(os.readlink(filepath), encoding=defenc))
return BytesIO(target)
else:

def open_stream() -> BinaryIO:
return open(filepath, "rb")
# Do not follow a final symlink or block on a FIFO substituted
# between lstat and open on platforms supporting these flags.
def opener(path: str, flags: int) -> int:
return os.open(path, flags | getattr(os, "O_NOFOLLOW", 0) | getattr(os, "O_NONBLOCK", 0))

return open(filepath, "rb", opener=opener)

with open_stream() as stream:
if not S_ISLNK(st.st_mode):
st = os.fstat(stream.fileno())
if not S_ISREG(st.st_mode):
raise ValueError("Can only stage a regular file: %r" % filepath)
stream_size = st.st_size
fprogress(filepath, False, filepath)
istream = self.repo.odb.store(IStream(Blob.type, st.st_size, stream))
istream = self.repo.odb.store(IStream(Blob.type, stream_size, stream))
fprogress(filepath, True, filepath)
return BaseIndexEntry(
(
Expand Down Expand Up @@ -777,7 +813,7 @@ def _entries_for_paths(
blob = Blob(
self.repo,
Blob.NULL_BIN_SHA,
stat_mode_to_index_mode(os.stat(abspath).st_mode),
stat_mode_to_index_mode(os.lstat(abspath).st_mode),
to_native_path_linux(gitrelative_path),
)
# TODO: variable undefined
Expand Down Expand Up @@ -989,6 +1025,8 @@ def handle_null_entries(self: "IndexFile") -> None:

# FINALIZE
# Add the new entries to this instance.
for entry in entries_added:
_validate_repo_path(entry.path)
for entry in entries_added:
self.entries[(entry.path, 0)] = IndexEntry.from_base(entry)

Expand Down Expand Up @@ -1390,6 +1428,9 @@ def handle_stderr(proc: "Popen[bytes]", iter_checked_out_files: Iterable[PathLik

# END stderr handler

# Read and validate the index before Git trusts its paths for checkout.
self._delete_entries_cache()
self.entries # noqa: B018
if paths is None:
args.append("--all")
kwargs["as_process"] = 1
Expand Down
48 changes: 39 additions & 9 deletions git/index/fun.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
traverse_trees_recursive,
tree_to_stream,
)
from git.util import IndexFileSHA1Writer, finalize_process
from git.util import IndexFileSHA1Writer, finalize_process, _validate_repo_path

from .typ import CE_EXTENDED, BaseIndexEntry, IndexEntry, CE_NAMEMASK, CE_STAGESHIFT
from .util import pack, unpack
Expand Down Expand Up @@ -279,13 +279,13 @@ def write_cache(

# Body
for entry in entries:
_validate_repo_path(entry.path)
beginoffset = tell()
write(entry.ctime_bytes) # ctime
write(entry.mtime_bytes) # mtime
path_str = str(entry.path)
path_str = os.fspath(entry.path)
path: bytes = force_bytes(path_str, encoding=defenc)
plen = len(path) & CE_NAMEMASK # Path length
assert plen == len(path), "Path %s too long to fit into index" % entry.path
plen = min(len(path), CE_NAMEMASK) # Longer names use a sentinel.
flags = plen | (entry.flags & CE_NAMEMASK_INV) # Clear possible previous values.
if entry.extended_flags:
flags |= CE_EXTENDED
Expand Down Expand Up @@ -325,7 +325,8 @@ def read_header(stream: IO[bytes]) -> Tuple[int, int]:
unpacked = cast(Tuple[int, int], unpack(">LL", stream.read(4 * 2)))
version, num_entries = unpacked

assert version in (1, 2, 3), "Unsupported git index version %i, only 1, 2, and 3 are supported" % version
if version not in (1, 2, 3):
raise AssertionError("Unsupported git index version %i, only 1, 2, and 3 are supported" % version)
return version, num_entries


Expand Down Expand Up @@ -382,10 +383,23 @@ def read_cache(
if flags & CE_EXTENDED:
extended_flags = unpack(">H", read(2))[0]
path_size = flags & CE_NAMEMASK
path = read(path_size).decode(defenc)

real_size = (tell() - beginoffset + 8) & ~7
read((beginoffset + real_size) - tell())
path_bytes = bytearray(read(path_size))
if len(path_bytes) != path_size:
raise ValueError("Truncated index entry path")
terminator = read(1)
if path_size == CE_NAMEMASK:
while terminator and terminator != b"\0":
path_bytes.extend(terminator)
terminator = read(1)
if terminator != b"\0":
raise ValueError("Unterminated index entry path")
path = path_bytes.decode(defenc)
_validate_repo_path(path)

real_size = (tell() - beginoffset + 7) & ~7
padding_size = beginoffset + real_size - tell()
if read(padding_size) != b"\0" * padding_size:
raise ValueError("Invalid index entry padding")
entry = IndexEntry((mode, sha, flags, path, ctime, mtime, dev, ino, uid, gid, size, extended_flags))
# entry_key would be the method to use, but we save the effort.
entries[(path, entry.stage)] = entry
Expand All @@ -408,6 +422,18 @@ def read_cache(
# Truncate the sha in the end as we will dynamically create it anyway.
extension_data = extension_data[:-20]

offset = 0
while offset < len(extension_data):
header = extension_data[offset : offset + 8]
if len(header) != 8:
raise ValueError("Truncated index extension header")
signature, size = unpack(">4sL", header)
if not b"A" <= signature[:1] <= b"Z":
raise ValueError("Unsupported mandatory index extension %r" % signature)
offset += 8 + size
if offset > len(extension_data):
raise ValueError("Truncated index extension %r" % signature)

return (version, entries, extension_data, content_sha)


Expand All @@ -434,6 +460,9 @@ def write_tree_from_cache(

A tuple of a sha and a list of tree entries being a tuple of hexsha, mode, name.
"""
if si == 0:
for entry in entries[sl]:
_validate_repo_path(entry.path)
tree_items: List["TreeCacheTup"] = []

ci = sl.start
Expand Down Expand Up @@ -481,6 +510,7 @@ def write_tree_from_cache(


def _tree_entry_to_baseindexentry(tree_entry: "TreeCacheTup", stage: int) -> BaseIndexEntry:
_validate_repo_path(tree_entry[2])
return BaseIndexEntry((tree_entry[1], tree_entry[0], stage << CE_STAGESHIFT, tree_entry[2]))


Expand Down
Loading
Loading