Skip to content
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.

Commit bf919f6

Browse files
committedJan 21, 2024
Merge branch 'main' into fix-bash-exe
2 parents 50c74f4 + d28c20b commit bf919f6

18 files changed

+435
-154
lines changed
 

‎.pre-commit-config.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ repos:
2929
hooks:
3030
- id: shellcheck
3131
args: [--color]
32-
exclude: ^git/ext/
32+
exclude: ^test/fixtures/polyglot$|^git/ext/
3333

3434
- repo: https://github.com/pre-commit/pre-commit-hooks
3535
rev: v4.4.0

‎.readthedocs.yaml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# Read the Docs configuration file for Sphinx projects
2+
# See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
3+
4+
# Required
5+
version: 2
6+
7+
# Set the OS, Python version and other tools you might need
8+
build:
9+
os: ubuntu-22.04
10+
tools:
11+
python: "3.12"
12+
# You can also specify other tool versions:
13+
# nodejs: "20"
14+
# rust: "1.70"
15+
# golang: "1.20"
16+
17+
# Build documentation in the "docs/" directory with Sphinx
18+
sphinx:
19+
configuration: doc/source/conf.py
20+
# You can configure Sphinx to use a different builder, for instance use the dirhtml builder for simpler URLs
21+
# builder: "dirhtml"
22+
# Fail on all warnings to avoid broken references
23+
# fail_on_warning: true
24+
25+
# Optionally build your docs in additional formats such as PDF and ePub
26+
# formats:
27+
# - pdf
28+
# - epub
29+
30+
# Optional but recommended, declare the Python requirements required
31+
# to build your documentation
32+
# See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
33+
# python:
34+
# install:
35+
# - requirements: docs/requirements.txt

‎AUTHORS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ Contributors are:
5353
-Santos Gallegos <stsewd _at_ proton.me>
5454
-Wenhan Zhu <wzhu.cosmos _at_ gmail.com>
5555
-Eliah Kagan <eliah.kagan _at_ gmail.com>
56+
-Ethan Lin <et.repositories _at_ gmail.com>
5657
-Randy Eckman <emanspeaks _at_ gmail.com>
5758

5859
Portions derived from other open source works and are clearly marked.

‎VERSION

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
3.1.40
1+
3.1.41

‎doc/requirements.txt

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1-
sphinx==4.3.0
1+
sphinx == 4.3.2
22
sphinx_rtd_theme
3+
sphinxcontrib-applehelp >= 1.0.2, <= 1.0.4
4+
sphinxcontrib-devhelp == 1.0.2
5+
sphinxcontrib-htmlhelp >= 2.0.0, <= 2.0.1
6+
sphinxcontrib-qthelp == 1.0.3
7+
sphinxcontrib-serializinghtml == 1.1.5
38
sphinx-autodoc-typehints

‎doc/source/changes.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,18 @@
22
Changelog
33
=========
44

5+
3.1.41
6+
======
7+
8+
This release is relevant for security as it fixes a possible arbitary
9+
code execution on Windows.
10+
11+
See this PR for details: https://github.com/gitpython-developers/GitPython/pull/1792
12+
An advisory is available soon at: https://github.com/gitpython-developers/GitPython/security/advisories/GHSA-2mqj-m65w-jghx
13+
14+
See the following for all changes.
15+
https://github.com/gitpython-developers/GitPython/releases/tag/3.1.41
16+
517
3.1.40
618
======
719

‎doc/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,7 @@
9393
# Options for HTML output
9494
# -----------------------
9595

96-
html_theme = "sphinx_rtd_theme"
96+
# html_theme = "sphinx_rtd_theme"
9797
html_theme_options = {}
9898

9999
# The name for this set of Sphinx documents. If None, it defaults to

‎git/cmd.py

Lines changed: 76 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@
4747
Iterator,
4848
List,
4949
Mapping,
50+
Optional,
5051
Sequence,
5152
TYPE_CHECKING,
5253
TextIO,
@@ -103,7 +104,7 @@ def handle_process_output(
103104
Callable[[bytes, "Repo", "DiffIndex"], None],
104105
],
105106
stderr_handler: Union[None, Callable[[AnyStr], None], Callable[[List[AnyStr]], None]],
106-
finalizer: Union[None, Callable[[Union[subprocess.Popen, "Git.AutoInterrupt"]], None]] = None,
107+
finalizer: Union[None, Callable[[Union[Popen, "Git.AutoInterrupt"]], None]] = None,
107108
decode_streams: bool = True,
108109
kill_after_timeout: Union[None, float] = None,
109110
) -> None:
@@ -208,6 +209,68 @@ def pump_stream(
208209
finalizer(process)
209210

210211

212+
def _safer_popen_windows(
213+
command: Union[str, Sequence[Any]],
214+
*,
215+
shell: bool = False,
216+
env: Optional[Mapping[str, str]] = None,
217+
**kwargs: Any,
218+
) -> Popen:
219+
"""Call :class:`subprocess.Popen` on Windows but don't include a CWD in the search.
220+
221+
This avoids an untrusted search path condition where a file like ``git.exe`` in a
222+
malicious repository would be run when GitPython operates on the repository. The
223+
process using GitPython may have an untrusted repository's working tree as its
224+
current working directory. Some operations may temporarily change to that directory
225+
before running a subprocess. In addition, while by default GitPython does not run
226+
external commands with a shell, it can be made to do so, in which case the CWD of
227+
the subprocess, which GitPython usually sets to a repository working tree, can
228+
itself be searched automatically by the shell. This wrapper covers all those cases.
229+
230+
:note: This currently works by setting the ``NoDefaultCurrentDirectoryInExePath``
231+
environment variable during subprocess creation. It also takes care of passing
232+
Windows-specific process creation flags, but that is unrelated to path search.
233+
234+
:note: The current implementation contains a race condition on :attr:`os.environ`.
235+
GitPython isn't thread-safe, but a program using it on one thread should ideally
236+
be able to mutate :attr:`os.environ` on another, without unpredictable results.
237+
See comments in https://github.com/gitpython-developers/GitPython/pull/1650.
238+
"""
239+
# CREATE_NEW_PROCESS_GROUP is needed for some ways of killing it afterwards. See:
240+
# https://docs.python.org/3/library/subprocess.html#subprocess.Popen.send_signal
241+
# https://docs.python.org/3/library/subprocess.html#subprocess.CREATE_NEW_PROCESS_GROUP
242+
creationflags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
243+
244+
# When using a shell, the shell is the direct subprocess, so the variable must be
245+
# set in its environment, to affect its search behavior. (The "1" can be any value.)
246+
if shell:
247+
safer_env = {} if env is None else dict(env)
248+
safer_env["NoDefaultCurrentDirectoryInExePath"] = "1"
249+
else:
250+
safer_env = env
251+
252+
# When not using a shell, the current process does the search in a CreateProcessW
253+
# API call, so the variable must be set in our environment. With a shell, this is
254+
# unnecessary, in versions where https://github.com/python/cpython/issues/101283 is
255+
# patched. If not, in the rare case the ComSpec environment variable is unset, the
256+
# shell is searched for unsafely. Setting NoDefaultCurrentDirectoryInExePath in all
257+
# cases, as here, is simpler and protects against that. (The "1" can be any value.)
258+
with patch_env("NoDefaultCurrentDirectoryInExePath", "1"):
259+
return Popen(
260+
command,
261+
shell=shell,
262+
env=safer_env,
263+
creationflags=creationflags,
264+
**kwargs,
265+
)
266+
267+
268+
if os.name == "nt":
269+
safer_popen = _safer_popen_windows
270+
else:
271+
safer_popen = Popen
272+
273+
211274
def dashify(string: str) -> str:
212275
return string.replace("_", "-")
213276

@@ -226,14 +289,6 @@ def dict_to_slots_and__excluded_are_none(self: object, d: Mapping[str, Any], exc
226289
## -- End Utilities -- @}
227290

228291

229-
if os.name == "nt":
230-
# CREATE_NEW_PROCESS_GROUP is needed to allow killing it afterwards. See:
231-
# https://docs.python.org/3/library/subprocess.html#subprocess.Popen.send_signal
232-
PROC_CREATIONFLAGS = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP
233-
else:
234-
PROC_CREATIONFLAGS = 0
235-
236-
237292
class Git(LazyMixin):
238293
"""The Git class manages communication with the Git binary.
239294
@@ -1160,11 +1215,8 @@ def execute(
11601215
redacted_command,
11611216
'"kill_after_timeout" feature is not supported on Windows.',
11621217
)
1163-
# Only search PATH, not CWD. This must be in the *caller* environment. The "1" can be any value.
1164-
maybe_patch_caller_env = patch_env("NoDefaultCurrentDirectoryInExePath", "1")
11651218
else:
11661219
cmd_not_found_exception = FileNotFoundError
1167-
maybe_patch_caller_env = contextlib.nullcontext()
11681220
# END handle
11691221

11701222
stdout_sink = PIPE if with_stdout else getattr(subprocess, "DEVNULL", None) or open(os.devnull, "wb")
@@ -1179,20 +1231,18 @@ def execute(
11791231
universal_newlines,
11801232
)
11811233
try:
1182-
with maybe_patch_caller_env:
1183-
proc = Popen(
1184-
command,
1185-
env=env,
1186-
cwd=cwd,
1187-
bufsize=-1,
1188-
stdin=(istream or DEVNULL),
1189-
stderr=PIPE,
1190-
stdout=stdout_sink,
1191-
shell=shell,
1192-
universal_newlines=universal_newlines,
1193-
creationflags=PROC_CREATIONFLAGS,
1194-
**subprocess_kwargs,
1195-
)
1234+
proc = safer_popen(
1235+
command,
1236+
env=env,
1237+
cwd=cwd,
1238+
bufsize=-1,
1239+
stdin=(istream or DEVNULL),
1240+
stderr=PIPE,
1241+
stdout=stdout_sink,
1242+
shell=shell,
1243+
universal_newlines=universal_newlines,
1244+
**subprocess_kwargs,
1245+
)
11961246
except cmd_not_found_exception as err:
11971247
raise GitCommandNotFound(redacted_command, err) from err
11981248
else:

‎git/index/fun.py

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
)
1919
import subprocess
2020

21-
from git.cmd import PROC_CREATIONFLAGS, handle_process_output, Git
21+
from git.cmd import handle_process_output, Git, safer_popen
2222
from git.compat import defenc, force_bytes, force_text, safe_decode
2323
from git.exc import HookExecutionError, UnmergedEntriesError
2424
from git.objects.fun import (
@@ -98,13 +98,12 @@ def run_commit_hook(name: str, index: "IndexFile", *args: str) -> None:
9898
relative_hp = Path(hp).relative_to(index.repo.working_dir).as_posix()
9999
cmd = [Git.GIT_PYTHON_BASH_EXECUTABLE, relative_hp]
100100

101-
process = subprocess.Popen(
101+
process = safer_popen(
102102
cmd + list(args),
103103
env=env,
104104
stdout=subprocess.PIPE,
105105
stderr=subprocess.PIPE,
106106
cwd=index.repo.working_dir,
107-
creationflags=PROC_CREATIONFLAGS,
108107
)
109108
except Exception as ex:
110109
raise HookExecutionError(hp, ex) from ex

‎git/objects/tree.py

Lines changed: 1 addition & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -53,54 +53,6 @@
5353
__all__ = ("TreeModifier", "Tree")
5454

5555

56-
def git_cmp(t1: TreeCacheTup, t2: TreeCacheTup) -> int:
57-
a, b = t1[2], t2[2]
58-
# assert isinstance(a, str) and isinstance(b, str)
59-
len_a, len_b = len(a), len(b)
60-
min_len = min(len_a, len_b)
61-
min_cmp = cmp(a[:min_len], b[:min_len])
62-
63-
if min_cmp:
64-
return min_cmp
65-
66-
return len_a - len_b
67-
68-
69-
def merge_sort(a: List[TreeCacheTup], cmp: Callable[[TreeCacheTup, TreeCacheTup], int]) -> None:
70-
if len(a) < 2:
71-
return
72-
73-
mid = len(a) // 2
74-
lefthalf = a[:mid]
75-
righthalf = a[mid:]
76-
77-
merge_sort(lefthalf, cmp)
78-
merge_sort(righthalf, cmp)
79-
80-
i = 0
81-
j = 0
82-
k = 0
83-
84-
while i < len(lefthalf) and j < len(righthalf):
85-
if cmp(lefthalf[i], righthalf[j]) <= 0:
86-
a[k] = lefthalf[i]
87-
i = i + 1
88-
else:
89-
a[k] = righthalf[j]
90-
j = j + 1
91-
k = k + 1
92-
93-
while i < len(lefthalf):
94-
a[k] = lefthalf[i]
95-
i = i + 1
96-
k = k + 1
97-
98-
while j < len(righthalf):
99-
a[k] = righthalf[j]
100-
j = j + 1
101-
k = k + 1
102-
103-
10456
class TreeModifier:
10557
"""A utility class providing methods to alter the underlying cache in a list-like fashion.
10658
@@ -131,7 +83,7 @@ def set_done(self) -> "TreeModifier":
13183
13284
:return self:
13385
"""
134-
merge_sort(self._cache, git_cmp)
86+
self._cache.sort(key=lambda x: (x[2] + "/") if x[1] == Tree.tree_id << 12 else x[2])
13587
return self
13688

13789
# } END interface

‎git/util.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,6 +327,17 @@ def _get_exe_extensions() -> Sequence[str]:
327327

328328

329329
def py_where(program: str, path: Optional[PathLike] = None) -> List[str]:
330+
"""Perform a path search to assist :func:`is_cygwin_git`.
331+
332+
This is not robust for general use. It is an implementation detail of
333+
:func:`is_cygwin_git`. When a search following all shell rules is needed,
334+
:func:`shutil.which` can be used instead.
335+
336+
:note: Neither this function nor :func:`shutil.which` will predict the effect of an
337+
executable search on a native Windows system due to a :class:`subprocess.Popen`
338+
call without ``shell=True``, because shell and non-shell executable search on
339+
Windows differ considerably.
340+
"""
330341
# From: http://stackoverflow.com/a/377028/548792
331342
winprog_exts = _get_exe_extensions()
332343

‎test-requirements.txt

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,4 +9,3 @@ pytest-cov
99
pytest-instafail
1010
pytest-mock
1111
pytest-sugar
12-
sumtypes

‎test/fixtures/polyglot

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#!/usr/bin/env sh
2+
# Valid script in both Bash and Python, but with different behavior.
3+
""":"
4+
echo 'Ran intended hook.' >output.txt
5+
exit
6+
" """
7+
from pathlib import Path
8+
Path('payload.txt').write_text('Ran impostor hook!', encoding='utf-8')

‎test/lib/helper.py

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import textwrap
1515
import time
1616
import unittest
17+
import venv
1718

1819
import gitdb
1920

@@ -36,6 +37,7 @@
3637
"with_rw_repo",
3738
"with_rw_and_rw_remote_repo",
3839
"TestBase",
40+
"VirtualEnvironment",
3941
"TestCase",
4042
"SkipTest",
4143
"skipIf",
@@ -88,11 +90,11 @@ def with_rw_directory(func):
8890
test succeeds, but leave it otherwise to aid additional debugging."""
8991

9092
@wraps(func)
91-
def wrapper(self):
93+
def wrapper(self, *args, **kwargs):
9294
path = tempfile.mkdtemp(prefix=func.__name__)
9395
keep = False
9496
try:
95-
return func(self, path)
97+
return func(self, path, *args, **kwargs)
9698
except Exception:
9799
log.info(
98100
"Test %s.%s failed, output is at %r\n",
@@ -390,3 +392,46 @@ def _make_file(self, rela_path, data, repo=None):
390392
with open(abs_path, "w") as fp:
391393
fp.write(data)
392394
return abs_path
395+
396+
397+
class VirtualEnvironment:
398+
"""A newly created Python virtual environment for use in a test."""
399+
400+
__slots__ = ("_env_dir",)
401+
402+
def __init__(self, env_dir, *, with_pip):
403+
if os.name == "nt":
404+
self._env_dir = osp.realpath(env_dir)
405+
venv.create(self.env_dir, symlinks=False, with_pip=with_pip)
406+
else:
407+
self._env_dir = env_dir
408+
venv.create(self.env_dir, symlinks=True, with_pip=with_pip)
409+
410+
@property
411+
def env_dir(self):
412+
"""The top-level directory of the environment."""
413+
return self._env_dir
414+
415+
@property
416+
def python(self):
417+
"""Path to the Python executable in the environment."""
418+
return self._executable("python")
419+
420+
@property
421+
def pip(self):
422+
"""Path to the pip executable in the environment, or RuntimeError if absent."""
423+
return self._executable("pip")
424+
425+
@property
426+
def sources(self):
427+
"""Path to a src directory in the environment, which may not exist yet."""
428+
return os.path.join(self.env_dir, "src")
429+
430+
def _executable(self, basename):
431+
if os.name == "nt":
432+
path = osp.join(self.env_dir, "Scripts", basename + ".exe")
433+
else:
434+
path = osp.join(self.env_dir, "bin", basename)
435+
if osp.isfile(path) or osp.islink(path):
436+
return path
437+
raise RuntimeError(f"no regular file or symlink {path!r}")

‎test/test_git.py

Lines changed: 70 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@
33
# This module is part of GitPython and is released under the
44
# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
55

6+
import contextlib
67
import gc
78
import inspect
89
import logging
910
import os
1011
import os.path as osp
12+
from pathlib import Path
1113
import re
1214
import shutil
1315
import subprocess
1416
import sys
15-
from tempfile import TemporaryDirectory, TemporaryFile
17+
from tempfile import TemporaryFile
1618
from unittest import skipUnless
1719

1820
if sys.version_info >= (3, 8):
@@ -27,6 +29,21 @@
2729
from test.lib import TestBase, fixture_path, with_rw_directory
2830

2931

32+
@contextlib.contextmanager
33+
def _patch_out_env(name):
34+
try:
35+
old_value = os.environ[name]
36+
except KeyError:
37+
old_value = None
38+
else:
39+
del os.environ[name]
40+
try:
41+
yield
42+
finally:
43+
if old_value is not None:
44+
os.environ[name] = old_value
45+
46+
3047
@ddt.ddt
3148
class TestGit(TestBase):
3249
@classmethod
@@ -97,29 +114,28 @@ def test_it_transforms_kwargs_into_git_command_arguments(self):
97114

98115
def _do_shell_combo(self, value_in_call, value_from_class):
99116
with mock.patch.object(Git, "USE_SHELL", value_from_class):
100-
# git.cmd gets Popen via a "from" import, so patch it there.
101-
with mock.patch.object(cmd, "Popen", wraps=cmd.Popen) as mock_popen:
117+
with mock.patch.object(cmd, "safer_popen", wraps=cmd.safer_popen) as mock_safer_popen:
102118
# Use a command with no arguments (besides the program name), so it runs
103119
# with or without a shell, on all OSes, with the same effect.
104120
self.git.execute(["git"], with_exceptions=False, shell=value_in_call)
105121

106-
return mock_popen
122+
return mock_safer_popen
107123

108124
@ddt.idata(_shell_cases)
109125
def test_it_uses_shell_or_not_as_specified(self, case):
110126
"""A bool passed as ``shell=`` takes precedence over `Git.USE_SHELL`."""
111127
value_in_call, value_from_class, expected_popen_arg = case
112-
mock_popen = self._do_shell_combo(value_in_call, value_from_class)
113-
mock_popen.assert_called_once()
114-
self.assertIs(mock_popen.call_args.kwargs["shell"], expected_popen_arg)
128+
mock_safer_popen = self._do_shell_combo(value_in_call, value_from_class)
129+
mock_safer_popen.assert_called_once()
130+
self.assertIs(mock_safer_popen.call_args.kwargs["shell"], expected_popen_arg)
115131

116132
@ddt.idata(full_case[:2] for full_case in _shell_cases)
117133
def test_it_logs_if_it_uses_a_shell(self, case):
118134
"""``shell=`` in the log message agrees with what is passed to `Popen`."""
119135
value_in_call, value_from_class = case
120136
with self.assertLogs(cmd.log, level=logging.DEBUG) as log_watcher:
121-
mock_popen = self._do_shell_combo(value_in_call, value_from_class)
122-
self._assert_logged_for_popen(log_watcher, "shell", mock_popen.call_args.kwargs["shell"])
137+
mock_safer_popen = self._do_shell_combo(value_in_call, value_from_class)
138+
self._assert_logged_for_popen(log_watcher, "shell", mock_safer_popen.call_args.kwargs["shell"])
123139

124140
@ddt.data(
125141
("None", None),
@@ -134,22 +150,49 @@ def test_it_logs_istream_summary_for_stdin(self, case):
134150
def test_it_executes_git_and_returns_result(self):
135151
self.assertRegex(self.git.execute(["git", "version"]), r"^git version [\d\.]{2}.*$")
136152

137-
def test_it_executes_git_not_from_cwd(self):
138-
with TemporaryDirectory() as tmpdir:
139-
if os.name == "nt":
140-
# Copy an actual binary executable that is not git.
141-
other_exe_path = os.path.join(os.getenv("WINDIR"), "system32", "hostname.exe")
142-
impostor_path = os.path.join(tmpdir, "git.exe")
143-
shutil.copy(other_exe_path, impostor_path)
144-
else:
145-
# Create a shell script that doesn't do anything.
146-
impostor_path = os.path.join(tmpdir, "git")
147-
with open(impostor_path, mode="w", encoding="utf-8") as file:
148-
print("#!/bin/sh", file=file)
149-
os.chmod(impostor_path, 0o755)
150-
151-
with cwd(tmpdir):
152-
self.assertRegex(self.git.execute(["git", "version"]), r"^git version\b")
153+
@ddt.data(
154+
# chdir_to_repo, shell, command, use_shell_impostor
155+
(False, False, ["git", "version"], False),
156+
(False, True, "git version", False),
157+
(False, True, "git version", True),
158+
(True, False, ["git", "version"], False),
159+
(True, True, "git version", False),
160+
(True, True, "git version", True),
161+
)
162+
@with_rw_directory
163+
def test_it_executes_git_not_from_cwd(self, rw_dir, case):
164+
chdir_to_repo, shell, command, use_shell_impostor = case
165+
166+
repo = Repo.init(rw_dir)
167+
168+
if os.name == "nt":
169+
# Copy an actual binary executable that is not git. (On Windows, running
170+
# "hostname" only displays the hostname, it never tries to change it.)
171+
other_exe_path = Path(os.environ["SystemRoot"], "system32", "hostname.exe")
172+
impostor_path = Path(rw_dir, "git.exe")
173+
shutil.copy(other_exe_path, impostor_path)
174+
else:
175+
# Create a shell script that doesn't do anything.
176+
impostor_path = Path(rw_dir, "git")
177+
impostor_path.write_text("#!/bin/sh\n", encoding="utf-8")
178+
os.chmod(impostor_path, 0o755)
179+
180+
if use_shell_impostor:
181+
shell_name = "cmd.exe" if os.name == "nt" else "sh"
182+
shutil.copy(impostor_path, Path(rw_dir, shell_name))
183+
184+
with contextlib.ExitStack() as stack:
185+
if chdir_to_repo:
186+
stack.enter_context(cwd(rw_dir))
187+
if use_shell_impostor:
188+
stack.enter_context(_patch_out_env("ComSpec"))
189+
190+
# Run the command without raising an exception on failure, as the exception
191+
# message is currently misleading when the command is a string rather than a
192+
# sequence of strings (it really runs "git", but then wrongly reports "g").
193+
output = repo.git.execute(command, with_exceptions=False, shell=shell)
194+
195+
self.assertRegex(output, r"^git version\b")
153196

154197
@skipUnless(
155198
os.name == "nt",
@@ -345,7 +388,7 @@ def test_environment(self, rw_dir):
345388
self.assertIn("FOO", str(err))
346389

347390
def test_handle_process_output(self):
348-
from git.cmd import handle_process_output
391+
from git.cmd import handle_process_output, safer_popen
349392

350393
line_count = 5002
351394
count = [None, 0, 0]
@@ -361,13 +404,12 @@ def counter_stderr(line):
361404
fixture_path("cat_file.py"),
362405
str(fixture_path("issue-301_stderr")),
363406
]
364-
proc = subprocess.Popen(
407+
proc = safer_popen(
365408
cmdline,
366409
stdin=None,
367410
stdout=subprocess.PIPE,
368411
stderr=subprocess.PIPE,
369412
shell=False,
370-
creationflags=cmd.PROC_CREATIONFLAGS,
371413
)
372414

373415
handle_process_output(proc, counter_stdout, counter_stderr, finalize_process)

‎test/test_index.py

Lines changed: 85 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -3,18 +3,21 @@
33
# This module is part of GitPython and is released under the
44
# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
55

6+
import contextlib
7+
from dataclasses import dataclass
68
from io import BytesIO
79
import logging
810
import os
911
import os.path as osp
1012
from pathlib import Path
1113
import re
14+
import shutil
1215
from stat import S_ISLNK, ST_MODE
1316
import subprocess
1417
import tempfile
1518

19+
import ddt
1620
import pytest
17-
from sumtypes import constructor, sumtype
1821

1922
from git import (
2023
BlobFilter,
@@ -36,9 +39,16 @@
3639
from git.index.typ import BaseIndexEntry, IndexEntry
3740
from git.index.util import TemporaryFileSwap
3841
from git.objects import Blob
39-
from git.util import Actor, hex_to_bin, rmtree
42+
from git.util import Actor, cwd, hex_to_bin, rmtree
4043
from gitdb.base import IStream
41-
from test.lib import TestBase, fixture, fixture_path, with_rw_directory, with_rw_repo
44+
from test.lib import (
45+
TestBase,
46+
VirtualEnvironment,
47+
fixture,
48+
fixture_path,
49+
with_rw_directory,
50+
with_rw_repo,
51+
)
4252

4353
HOOKS_SHEBANG = "#!/usr/bin/env sh\n"
4454

@@ -56,34 +66,48 @@ def _get_windows_ansi_encoding():
5666
return f"cp{value}"
5767

5868

59-
@sumtype
6069
class WinBashStatus:
61-
"""Status of bash.exe for native Windows. Affects which commit hook tests can pass.
70+
"""Namespace of native-Windows bash.exe statuses. Affects what hook tests can pass.
6271
6372
Call check() to check the status. (CheckError and WinError should not typically be
6473
used to trigger skip or xfail, because they represent unexpected situations.)
6574
"""
6675

67-
Inapplicable = constructor()
68-
"""This system is not native Windows: either not Windows at all, or Cygwin."""
76+
@dataclass
77+
class Inapplicable:
78+
"""This system is not native Windows: either not Windows at all, or Cygwin."""
79+
80+
@dataclass
81+
class Absent:
82+
"""No command for bash.exe is found on the system."""
83+
84+
@dataclass
85+
class Native:
86+
"""Running bash.exe operates outside any WSL distribution (as with Git Bash)."""
87+
88+
@dataclass
89+
class Wsl:
90+
"""Running bash.exe calls bash in a WSL distribution."""
6991

70-
Absent = constructor()
71-
"""No command for bash.exe is found on the system."""
92+
@dataclass
93+
class WslNoDistro:
94+
"""Running bash.exe tries to run bash on a WSL distribution, but none exists."""
7295

73-
Native = constructor()
74-
"""Running bash.exe operates outside any WSL distribution (as with Git Bash)."""
96+
process: "subprocess.CompletedProcess[bytes]"
97+
message: str
7598

76-
Wsl = constructor()
77-
"""Running bash.exe calls bash in a WSL distribution."""
99+
@dataclass
100+
class CheckError:
101+
"""Running bash.exe fails in an unexpected error or gives unexpected output."""
78102

79-
WslNoDistro = constructor("process", "message")
80-
"""Running bash.exe tries to run bash on a WSL distribution, but none exists."""
103+
process: "subprocess.CompletedProcess[bytes]"
104+
message: str
81105

82-
CheckError = constructor("process", "message")
83-
"""Running bash.exe fails in an unexpected error or gives unexpected output."""
106+
@dataclass
107+
class WinError:
108+
"""bash.exe may exist but can't run. CreateProcessW fails unexpectedly."""
84109

85-
WinError = constructor("exception")
86-
"""bash.exe may exist but can't run. CreateProcessW fails unexpectedly."""
110+
exception: OSError
87111

88112
@classmethod
89113
def check(cls):
@@ -172,6 +196,7 @@ def _make_hook(git_dir, name, content, make_exec=True):
172196
return hp
173197

174198

199+
@ddt.ddt
175200
class TestIndex(TestBase):
176201
def __init__(self, *args):
177202
super().__init__(*args)
@@ -1012,6 +1037,47 @@ def test_run_commit_hook(self, rw_repo):
10121037
output = Path(rw_repo.git_dir, "output.txt").read_text(encoding="utf-8")
10131038
self.assertEqual(output, "ran fake hook\n")
10141039

1040+
@ddt.data((False,), (True,))
1041+
@with_rw_directory
1042+
def test_hook_uses_shell_not_from_cwd(self, rw_dir, case):
1043+
(chdir_to_repo,) = case
1044+
1045+
shell_name = "bash.exe" if os.name == "nt" else "sh"
1046+
maybe_chdir = cwd(rw_dir) if chdir_to_repo else contextlib.nullcontext()
1047+
repo = Repo.init(rw_dir)
1048+
1049+
# We need an impostor shell that works on Windows and that the test can
1050+
# distinguish from the real bash.exe. But even if the real bash.exe is absent or
1051+
# unusable, we should verify the impostor is not run. So the impostor needs a
1052+
# clear side effect (unlike in TestGit.test_it_executes_git_not_from_cwd). Popen
1053+
# on Windows uses CreateProcessW, which disregards PATHEXT; the impostor may
1054+
# need to be a binary executable to ensure the vulnerability is found if
1055+
# present. No compiler need exist, shipping a binary in the test suite may
1056+
# target the wrong architecture, and generating one in a bespoke way may trigger
1057+
# false positive virus scans. So we use a Bash/Python polyglot for the hook and
1058+
# use the Python interpreter itself as the bash.exe impostor. But an interpreter
1059+
# from a venv may not run when copied outside of it, and a global interpreter
1060+
# won't run when copied to a different location if it was installed from the
1061+
# Microsoft Store. So we make a new venv in rw_dir and use its interpreter.
1062+
venv = VirtualEnvironment(rw_dir, with_pip=False)
1063+
shutil.copy(venv.python, Path(rw_dir, shell_name))
1064+
shutil.copy(fixture_path("polyglot"), hook_path("polyglot", repo.git_dir))
1065+
payload = Path(rw_dir, "payload.txt")
1066+
1067+
if type(_win_bash_status) in {WinBashStatus.Absent, WinBashStatus.WslNoDistro}:
1068+
# The real shell can't run, but the impostor should still not be used.
1069+
with self.assertRaises(HookExecutionError):
1070+
with maybe_chdir:
1071+
run_commit_hook("polyglot", repo.index)
1072+
self.assertFalse(payload.exists())
1073+
else:
1074+
# The real shell should run, and not the impostor.
1075+
with maybe_chdir:
1076+
run_commit_hook("polyglot", repo.index)
1077+
self.assertFalse(payload.exists())
1078+
output = Path(rw_dir, "output.txt").read_text(encoding="utf-8")
1079+
self.assertEqual(output, "Ran intended hook.\n")
1080+
10151081
@pytest.mark.xfail(
10161082
type(_win_bash_status) is WinBashStatus.Absent,
10171083
reason="Can't run a hook on Windows without bash.exe.",

‎test/test_installation.py

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -4,31 +4,19 @@
44
import ast
55
import os
66
import subprocess
7-
import sys
87

9-
from test.lib import TestBase
10-
from test.lib.helper import with_rw_directory
8+
from test.lib import TestBase, VirtualEnvironment, with_rw_directory
119

1210

1311
class TestInstallation(TestBase):
14-
def setUp_venv(self, rw_dir):
15-
self.venv = rw_dir
16-
subprocess.run([sys.executable, "-m", "venv", self.venv], stdout=subprocess.PIPE)
17-
bin_name = "Scripts" if os.name == "nt" else "bin"
18-
self.python = os.path.join(self.venv, bin_name, "python")
19-
self.pip = os.path.join(self.venv, bin_name, "pip")
20-
self.sources = os.path.join(self.venv, "src")
21-
self.cwd = os.path.dirname(os.path.dirname(__file__))
22-
os.symlink(self.cwd, self.sources, target_is_directory=True)
23-
2412
@with_rw_directory
2513
def test_installation(self, rw_dir):
26-
self.setUp_venv(rw_dir)
14+
venv = self._set_up_venv(rw_dir)
2715

2816
result = subprocess.run(
29-
[self.pip, "install", "."],
17+
[venv.pip, "install", "."],
3018
stdout=subprocess.PIPE,
31-
cwd=self.sources,
19+
cwd=venv.sources,
3220
)
3321
self.assertEqual(
3422
0,
@@ -37,9 +25,9 @@ def test_installation(self, rw_dir):
3725
)
3826

3927
result = subprocess.run(
40-
[self.python, "-c", "import git"],
28+
[venv.python, "-c", "import git"],
4129
stdout=subprocess.PIPE,
42-
cwd=self.sources,
30+
cwd=venv.sources,
4331
)
4432
self.assertEqual(
4533
0,
@@ -48,9 +36,9 @@ def test_installation(self, rw_dir):
4836
)
4937

5038
result = subprocess.run(
51-
[self.python, "-c", "import gitdb; import smmap"],
39+
[venv.python, "-c", "import gitdb; import smmap"],
5240
stdout=subprocess.PIPE,
53-
cwd=self.sources,
41+
cwd=venv.sources,
5442
)
5543
self.assertEqual(
5644
0,
@@ -62,9 +50,9 @@ def test_installation(self, rw_dir):
6250
# by inserting its location into PYTHONPATH or otherwise patched into
6351
# sys.path, make sure it is not wrongly inserted as the *first* entry.
6452
result = subprocess.run(
65-
[self.python, "-c", "import sys; import git; print(sys.path)"],
53+
[venv.python, "-c", "import sys; import git; print(sys.path)"],
6654
stdout=subprocess.PIPE,
67-
cwd=self.sources,
55+
cwd=venv.sources,
6856
)
6957
syspath = result.stdout.decode("utf-8").splitlines()[0]
7058
syspath = ast.literal_eval(syspath)
@@ -73,3 +61,13 @@ def test_installation(self, rw_dir):
7361
syspath[0],
7462
msg="Failed to follow the conventions for https://docs.python.org/3/library/sys.html#sys.path",
7563
)
64+
65+
@staticmethod
66+
def _set_up_venv(rw_dir):
67+
venv = VirtualEnvironment(rw_dir, with_pip=True)
68+
os.symlink(
69+
os.path.dirname(os.path.dirname(__file__)),
70+
venv.sources,
71+
target_is_directory=True,
72+
)
73+
return venv

‎test/test_tree.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
from git.objects import Tree, Blob
99
from test.lib import TestBase
1010

11+
import os
1112
import os.path as osp
13+
import subprocess
1214

1315

1416
class TestTree(TestBase):
@@ -40,6 +42,62 @@ def test_serializable(self):
4042
testtree._deserialize(stream)
4143
# END for each item in tree
4244

45+
def test_tree_modifier_ordering(self):
46+
def setup_git_repository_and_get_ordered_files():
47+
os.mkdir("tmp")
48+
os.chdir("tmp")
49+
subprocess.run(["git", "init", "-q"], check=True)
50+
os.mkdir("file")
51+
for filename in [
52+
"bin",
53+
"bin.d",
54+
"file.to",
55+
"file.toml",
56+
"file.toml.bin",
57+
"file0",
58+
"file/a",
59+
]:
60+
open(filename, "a").close()
61+
62+
subprocess.run(["git", "add", "."], check=True)
63+
subprocess.run(["git", "commit", "-m", "c1"], check=True)
64+
tree_hash = subprocess.check_output(["git", "rev-parse", "HEAD^{tree}"]).decode().strip()
65+
cat_file_output = subprocess.check_output(["git", "cat-file", "-p", tree_hash]).decode()
66+
return [line.split()[-1] for line in cat_file_output.split("\n") if line]
67+
68+
hexsha = "6c1faef799095f3990e9970bc2cb10aa0221cf9c"
69+
roottree = self.rorepo.tree(hexsha)
70+
blob_mode = Tree.blob_id << 12
71+
tree_mode = Tree.tree_id << 12
72+
73+
files_in_desired_order = [
74+
(blob_mode, "bin"),
75+
(blob_mode, "bin.d"),
76+
(blob_mode, "file.to"),
77+
(blob_mode, "file.toml"),
78+
(blob_mode, "file.toml.bin"),
79+
(blob_mode, "file0"),
80+
(tree_mode, "file"),
81+
]
82+
mod = roottree.cache
83+
for file_mode, file_name in files_in_desired_order:
84+
mod.add(hexsha, file_mode, file_name)
85+
# end for each file
86+
87+
def file_names_in_order():
88+
return [t[1] for t in files_in_desired_order]
89+
90+
def names_in_mod_cache():
91+
a = [t[2] for t in mod._cache]
92+
here = file_names_in_order()
93+
return [e for e in a if e in here]
94+
95+
git_file_names_in_order = setup_git_repository_and_get_ordered_files()
96+
os.chdir("..")
97+
98+
mod.set_done()
99+
assert names_in_mod_cache() == git_file_names_in_order, "set_done() performs git-sorting"
100+
43101
def test_traverse(self):
44102
root = self.rorepo.tree("0.1.6")
45103
num_recursive = 0

0 commit comments

Comments
 (0)
Please sign in to comment.