Skip to content

Commit a4dc70d

Browse files
authored
Merge pull request #2180 from gitpython-developers/single-char-kwarg
Harden unsafe Git option validation
2 parents faf3c09 + 1d51b89 commit a4dc70d

7 files changed

Lines changed: 79 additions & 4 deletions

File tree

git/cmd.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1039,11 +1039,18 @@ def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[s
10391039
option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-")
10401040
]
10411041
if kwargs:
1042+
split_single_char_options = kwargs.get("split_single_char_options", True)
10421043
for key, value in kwargs.items():
10431044
values = value if isinstance(value, (list, tuple)) else (value,)
10441045
if any(value is True or (value is not False and value is not None) for value in values):
10451046
key = str(key)
10461047
options.append(f"-{key}" if len(key) == 1 else f"--{dashify(key)}")
1048+
if len(key) == 1 and split_single_char_options:
1049+
options.extend(
1050+
str(value)
1051+
for value in values
1052+
if value is not True and value not in (False, None) and str(value).startswith("-")
1053+
)
10471054
return options
10481055

10491056
AutoInterrupt: TypeAlias = _AutoInterrupt

git/diff.py

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import re
1010
import warnings
1111

12-
from git.cmd import handle_process_output
12+
from git.cmd import Git, handle_process_output
1313
from git.compat import defenc
1414
from git.objects.blob import Blob
1515
from git.objects.util import mode_str_to_int
@@ -35,7 +35,6 @@
3535
if TYPE_CHECKING:
3636
from subprocess import Popen
3737

38-
from git.cmd import Git
3938
from git.objects.base import IndexObject
4039
from git.objects.commit import Commit
4140
from git.objects.tree import Tree
@@ -190,6 +189,7 @@ def diff(
190189
other: Union[DiffConstants, "Tree", "Commit", str, None] = INDEX,
191190
paths: Union[PathLike, List[PathLike], Tuple[PathLike, ...], None] = None,
192191
create_patch: bool = False,
192+
allow_unsafe_options: bool = False,
193193
**kwargs: Any,
194194
) -> "DiffIndex[Diff]":
195195
"""Create diffs between two items being trees, trees and index or an index and
@@ -219,6 +219,10 @@ def diff(
219219
applied makes the self to other. Patches are somewhat costly as blobs have
220220
to be read and diffed.
221221
222+
:param allow_unsafe_options:
223+
If ``True``, allow options such as ``--output`` that can write to arbitrary
224+
filesystem paths.
225+
222226
:param kwargs:
223227
Additional arguments passed to :manpage:`git-diff(1)`, such as ``R=True`` to
224228
swap both sides of the diff.
@@ -231,6 +235,12 @@ def diff(
231235
an instance of :class:`~git.objects.tree.Tree` or
232236
:class:`~git.objects.commit.Commit`, or a git command error will occur.
233237
"""
238+
if not allow_unsafe_options:
239+
Git.check_unsafe_options(
240+
options=Git._option_candidates([other], kwargs),
241+
unsafe_options=self.repo.unsafe_git_revision_options,
242+
)
243+
234244
args: List[Union[PathLike, Diffable]] = []
235245
args.append("--abbrev=40") # We need full shas.
236246
args.append("--full-index") # Get full index paths, not only filenames.

git/index/base.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
from gitdb.db import MemoryDB
2424

2525
from git.compat import defenc, force_bytes
26+
from git.cmd import Git
2627
import git.diff as git_diff
2728
from git.exc import CheckoutError, GitCommandError, GitError, InvalidGitRepositoryError
2829
from git.objects import Blob, Commit, Object, Submodule, Tree
@@ -1492,6 +1493,7 @@ def diff(
14921493
] = git_diff.INDEX,
14931494
paths: Union[PathLike, List[PathLike], Tuple[PathLike, ...], None] = None,
14941495
create_patch: bool = False,
1496+
allow_unsafe_options: bool = False,
14951497
**kwargs: Any,
14961498
) -> git_diff.DiffIndex[git_diff.Diff]:
14971499
"""Diff this index against the working copy or a :class:`~git.objects.tree.Tree`
@@ -1504,6 +1506,12 @@ def diff(
15041506
Will only work with indices that represent the default git index as they
15051507
have not been initialized with a stream.
15061508
"""
1509+
if not allow_unsafe_options:
1510+
Git.check_unsafe_options(
1511+
options=Git._option_candidates([other], kwargs),
1512+
unsafe_options=self.repo.unsafe_git_revision_options,
1513+
)
1514+
15071515
# Only run if we are the default repository index.
15081516
if self._file_path != self._index_path():
15091517
raise AssertionError("Cannot call %r on indices that do not represent the default git index" % self.diff())
@@ -1560,12 +1568,18 @@ def diff(
15601568
# Invert the existing R flag.
15611569
cur_val = kwargs.get("R", False)
15621570
kwargs["R"] = not cur_val
1563-
return other.diff(self.INDEX, paths, create_patch, **kwargs)
1571+
return other.diff(
1572+
self.INDEX,
1573+
paths,
1574+
create_patch,
1575+
allow_unsafe_options=allow_unsafe_options,
1576+
**kwargs,
1577+
)
15641578
# END diff against other item handling
15651579

15661580
# If other is not None here, something is wrong.
15671581
if other is not None:
15681582
raise ValueError("other must be None, Diffable.INDEX, a Tree or Commit, was %r" % other)
15691583

15701584
# Diff against working copy - can be handled by superclass natively.
1571-
return super().diff(other, paths, create_patch, **kwargs)
1585+
return super().diff(other, paths, create_patch, allow_unsafe_options=allow_unsafe_options, **kwargs)

git/repo/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,8 @@ class Repo:
149149
# Can override configuration variables that execute arbitrary commands:
150150
"--config",
151151
"-c",
152+
# Can install hooks that execute during clone:
153+
"--template",
152154
]
153155
"""Options to :manpage:`git-clone(1)` that allow arbitrary commands to be executed.
154156
@@ -159,6 +161,9 @@ class Repo:
159161
The ``--config``/``-c`` option allows users to override configuration variables like
160162
``protocol.allow`` and ``core.gitProxy`` to execute arbitrary commands:
161163
https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---configltkeygtltvaluegt
164+
165+
The ``--template`` option can install hooks that execute during clone:
166+
https://git-scm.com/docs/git-clone#Documentation/git-clone.txt---templatetemplate-directory
162167
"""
163168

164169
unsafe_git_archive_options = [

test/test_clone.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ def test_clone_unsafe_options(self, rw_repo):
128128
"-c protocol.ext.allow=always",
129129
"-cprotocol.ext.allow=always",
130130
"-vcprotocol.ext.allow=always",
131+
f"--template={tmp_dir}",
131132
]
132133
for unsafe_option in unsafe_options:
133134
with self.assertRaises(UnsafeOptionError):
@@ -142,6 +143,7 @@ def test_clone_unsafe_options(self, rw_repo):
142143
{"config": "protocol.ext.allow=always"},
143144
{"conf": "protocol.ext.allow=always"},
144145
{"c": "protocol.ext.allow=always"},
146+
{"template": tmp_dir},
145147
]
146148
for unsafe_option in unsafe_options:
147149
with self.assertRaises(UnsafeOptionError):

test/test_diff.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from git import NULL_TREE, Diff, DiffIndex, Diffable, GitCommandError, Repo, Submodule
1616
from git.cmd import Git
17+
from git.exc import UnsafeOptionError
1718

1819
from test.lib import StringProcessAdapter, TestBase, fixture, with_rw_directory
1920

@@ -352,6 +353,25 @@ def test_diff_submodule(self):
352353
self.assertIsInstance(diff.a_blob.size, int)
353354
self.assertIsInstance(diff.b_blob.size, int)
354355

356+
def test_diff_rejects_unsafe_output_options(self):
357+
commit = self.rorepo.head.commit
358+
359+
calls = (
360+
lambda target: commit.diff(output=target),
361+
lambda target: commit.diff(other=f"--output={target}"),
362+
lambda target: self.rorepo.index.diff(NULL_TREE, output=target),
363+
lambda target: self.rorepo.index.diff(f"--output={target}"),
364+
)
365+
for index, call in enumerate(calls):
366+
target = osp.join(self.repo_dir, f"diff-output-{index}")
367+
with self.assertRaises(UnsafeOptionError):
368+
call(target)
369+
self.assertFalse(osp.exists(target))
370+
371+
allowed_target = osp.join(self.repo_dir, "allowed-diff-output")
372+
commit.diff(output=allowed_target, allow_unsafe_options=True)
373+
self.assertTrue(osp.isfile(allowed_target))
374+
355375
def test_diff_interface(self):
356376
"""Test a few variations of the main diff routine."""
357377
assertion_map = {}

test/test_git.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,23 @@ def test_option_candidates_ignore_untransformed_kwargs(self):
214214

215215
self.assertEqual(options, ["--max-count"])
216216

217+
def test_option_candidates_include_split_single_char_option_values(self):
218+
cases = [
219+
({"n": "--upload-pack=helper"}, ["-n", "--upload-pack=helper"], ["--upload-pack"]),
220+
({"g": ("safe", "--out=target")}, ["-g", "--out=target"], ["--output"]),
221+
]
222+
223+
for kwargs, candidates, unsafe_options in cases:
224+
self.assertEqual(Git._option_candidates(kwargs=kwargs), candidates)
225+
with self.assertRaises(UnsafeOptionError):
226+
Git.check_unsafe_options(options=candidates, unsafe_options=unsafe_options)
227+
228+
self.assertEqual(self.git.transform_kwargs(n="--upload-pack=helper"), ["-n", "--upload-pack=helper"])
229+
230+
unsplit_kwargs = {"n": "--upload-pack=helper", "split_single_char_options": False}
231+
self.assertEqual(self.git.transform_kwargs(**unsplit_kwargs), ["-n--upload-pack=helper"])
232+
self.assertEqual(Git._option_candidates(kwargs=unsplit_kwargs), ["-n"])
233+
217234
_shell_cases = (
218235
# value_in_call, value_from_class, expected_popen_arg
219236
(None, False, False),

0 commit comments

Comments
 (0)