Skip to content

Commit f982935

Browse files
codexByron
authored andcommitted
Refactor unsafe option candidate checks
Centralize unsafe-option candidate gathering so callers consistently validate keyword spellings before Git command execution, and validate only positional values that actually look like options. This makes the existing canonical unsafe-option checker explicit at each call site and avoids duplicating ad hoc kwargs handling. Clean up the archive unsafe option list to only include git-archive options, add unsafe protocol validation for archive remotes without changing the existing positional allow_unsafe_options API, accept tuple-style blame rev_opts by normalizing them once, and update regression tests to use non-existent marker paths so they assert blocked commands do not create output. Validation: - uv run --with-requirements requirements.txt --with-requirements test-requirements.txt pytest test/test_git.py::TestGit::test_check_unsafe_options_normalizes_kwargs test/test_commit.py::TestCommit::test_iter_items_rejects_unsafe_revision test/test_commit.py::TestCommit::test_iter_items_rejects_unsafe_options test/test_repo.py::TestRepo::test_archive_rejects_unsafe_options test/test_repo.py::TestRepo::test_archive_rejects_unsafe_remote_protocol test/test_repo.py::TestRepo::test_archive_preserves_positional_allow_unsafe_options test/test_repo.py::TestRepo::test_archive_accepts_stringifiable_remote test/test_repo.py::TestRepo::test_iter_commits_rejects_unsafe_revision test/test_repo.py::TestRepo::test_blame_rejects_unsafe_revision test/test_repo.py::TestRepo::test_blame_rejects_unsafe_options test/test_repo.py::TestRepo::test_blame_rejects_unsafe_rev_opts test/test_remote.py::TestRemote::test_ls_remote_unsafe_options test/test_remote.py::TestRemote::test_ls_remote_allows_operand_named_like_unsafe_option - uv run --with ruff ruff check git/cmd.py git/repo/base.py git/objects/commit.py git/remote.py test/test_repo.py test/test_remote.py
1 parent c1da935 commit f982935

6 files changed

Lines changed: 97 additions & 31 deletions

File tree

git/cmd.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -978,6 +978,16 @@ def check_unsafe_options(cls, options: List[str], unsafe_options: List[str]) ->
978978
if unsafe_option is not None:
979979
raise UnsafeOptionError(f"{unsafe_option} is not allowed, use `allow_unsafe_options=True` to allow it.")
980980

981+
@classmethod
982+
def _option_candidates(cls, args: Sequence[Any] = (), kwargs: Optional[Mapping[str, Any]] = None) -> List[str]:
983+
"""Collect possible option spellings before command-line transformation."""
984+
options = [
985+
option for option in cls._unpack_args([arg for arg in args if arg is not None]) if option.startswith("-")
986+
]
987+
if kwargs:
988+
options.extend(str(key) for key in kwargs)
989+
return options
990+
981991
AutoInterrupt: TypeAlias = _AutoInterrupt
982992

983993
CatFileContentStream: TypeAlias = _CatFileContentStream
@@ -1047,8 +1057,8 @@ def ls_remote(
10471057
Allow unsafe options, like ``--upload-pack``.
10481058
"""
10491059
if not allow_unsafe_options:
1050-
unsafe_options = list(kwargs.keys()) + self._unpack_args([a for a in args if a is not None])
1051-
Git.check_unsafe_options(options=unsafe_options, unsafe_options=self.unsafe_git_ls_remote_options)
1060+
candidate_options = self._option_candidates(args, kwargs)
1061+
Git.check_unsafe_options(options=candidate_options, unsafe_options=self.unsafe_git_ls_remote_options)
10521062
return self._call_process("ls_remote", *args, **kwargs)
10531063

10541064
@property
@@ -1557,7 +1567,7 @@ def transform_kwargs(self, split_single_char_options: bool = True, **kwargs: Any
15571567
return args
15581568

15591569
@classmethod
1560-
def _unpack_args(cls, arg_list: Sequence[str]) -> List[str]:
1570+
def _unpack_args(cls, arg_list: Sequence[Any]) -> List[str]:
15611571
outlist = []
15621572
if isinstance(arg_list, (list, tuple)):
15631573
for arg in arg_list:

git/objects/commit.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -340,7 +340,7 @@ def iter_items(
340340

341341
if not allow_unsafe_options:
342342
Git.check_unsafe_options(
343-
options=[str(rev)] + list(kwargs.keys()), unsafe_options=cls.unsafe_git_rev_options
343+
options=Git._option_candidates([rev], kwargs), unsafe_options=cls.unsafe_git_rev_options
344344
)
345345

346346
# Use -- in all cases, to prevent possibility of ambiguous arguments.

git/remote.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,7 +1071,10 @@ def fetch(
10711071
Git.check_unsafe_protocols(ref)
10721072

10731073
if not allow_unsafe_options:
1074-
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_fetch_options)
1074+
Git.check_unsafe_options(
1075+
options=Git._option_candidates([], kwargs),
1076+
unsafe_options=self.unsafe_git_fetch_options,
1077+
)
10751078

10761079
proc = self.repo.git.fetch(
10771080
"--", self, *args, as_process=True, with_stdout=False, universal_newlines=True, v=verbose, **kwargs
@@ -1125,7 +1128,10 @@ def pull(
11251128
Git.check_unsafe_protocols(ref)
11261129

11271130
if not allow_unsafe_options:
1128-
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_pull_options)
1131+
Git.check_unsafe_options(
1132+
options=Git._option_candidates([], kwargs),
1133+
unsafe_options=self.unsafe_git_pull_options,
1134+
)
11291135

11301136
proc = self.repo.git.pull(
11311137
"--", self, refspec, with_stdout=False, as_process=True, universal_newlines=True, v=True, **kwargs
@@ -1198,7 +1204,10 @@ def push(
11981204
Git.check_unsafe_protocols(ref)
11991205

12001206
if not allow_unsafe_options:
1201-
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_push_options)
1207+
Git.check_unsafe_options(
1208+
options=Git._option_candidates([], kwargs),
1209+
unsafe_options=self.unsafe_git_push_options,
1210+
)
12021211

12031212
proc = self.repo.git.push(
12041213
"--",

git/repo/base.py

Lines changed: 25 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -162,20 +162,18 @@ class Repo:
162162
"""
163163

164164
unsafe_git_archive_options = [
165-
# This option allows arbitrary command execution in git-archive.
166-
"--upload-pack",
165+
# Allows arbitrary command execution through the remote git-upload-archive command.
167166
"--exec",
167+
# Writes output to a caller-controlled filesystem path.
168168
"--output",
169169
"-o",
170170
]
171-
"""Options to :manpage:`git-archive(1)` that can be dangerous."""
172171

173172
unsafe_git_revision_options = [
174173
# This option allows output to be written to arbitrary files before revision parsing.
175174
"--output",
176175
"-o",
177176
]
178-
"""Options to :manpage:`git-rev-list(1)` / :manpage:`git-blame(1)` that can overwrite files."""
179177

180178
# Invariants
181179
config_level: ConfigLevels_Tup = ("system", "user", "global", "repository")
@@ -824,7 +822,7 @@ def iter_commits(
824822

825823
if not allow_unsafe_options:
826824
Git.check_unsafe_options(
827-
options=[str(rev)] + list(kwargs.keys()), unsafe_options=self.unsafe_git_revision_options
825+
options=Git._option_candidates([rev], kwargs), unsafe_options=self.unsafe_git_revision_options
828826
)
829827

830828
return Commit.iter_items(
@@ -1136,7 +1134,7 @@ def blame_incremental(
11361134
"""
11371135
if not allow_unsafe_options:
11381136
Git.check_unsafe_options(
1139-
options=[str(rev)] + list(kwargs.keys()), unsafe_options=self.unsafe_git_revision_options
1137+
options=Git._option_candidates([rev], kwargs), unsafe_options=self.unsafe_git_revision_options
11401138
)
11411139

11421140
data: bytes = self.git.blame(rev, "--", file, p=True, incremental=True, stdout_as_string=False, **kwargs)
@@ -1216,7 +1214,7 @@ def blame(
12161214
rev: Union[str, HEAD, None],
12171215
file: str,
12181216
incremental: bool = False,
1219-
rev_opts: Optional[List[str]] = None,
1217+
rev_opts: Optional[Sequence[str]] = None,
12201218
allow_unsafe_options: bool = False,
12211219
**kwargs: Any,
12221220
) -> List[List[Commit | List[str | bytes] | None]] | Iterator[BlameEntry] | None:
@@ -1240,13 +1238,13 @@ def blame(
12401238
"""
12411239
if incremental:
12421240
return self.blame_incremental(rev, file, allow_unsafe_options=allow_unsafe_options, **kwargs)
1241+
rev_opts_list = list(rev_opts or [])
12431242
if not allow_unsafe_options:
1244-
rev_opts = rev_opts or []
12451243
Git.check_unsafe_options(
1246-
options=[str(rev)] + rev_opts + list(kwargs.keys()), unsafe_options=self.unsafe_git_revision_options
1244+
options=Git._option_candidates([rev, rev_opts_list], kwargs),
1245+
unsafe_options=self.unsafe_git_revision_options,
12471246
)
1248-
rev_opts = rev_opts or []
1249-
data: bytes = self.git.blame(rev, *rev_opts, "--", file, p=True, stdout_as_string=False, **kwargs)
1247+
data: bytes = self.git.blame(rev, *rev_opts_list, "--", file, p=True, stdout_as_string=False, **kwargs)
12501248
commits: Dict[str, Commit] = {}
12511249
blames: List[List[Commit | List[str | bytes] | None]] = []
12521250

@@ -1457,7 +1455,10 @@ def _clone(
14571455
if not allow_unsafe_protocols:
14581456
Git.check_unsafe_protocols(url)
14591457
if not allow_unsafe_options:
1460-
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=cls.unsafe_git_clone_options)
1458+
Git.check_unsafe_options(
1459+
options=Git._option_candidates([], kwargs),
1460+
unsafe_options=cls.unsafe_git_clone_options,
1461+
)
14611462
if not allow_unsafe_options and multi:
14621463
Git.check_unsafe_options(options=multi, unsafe_options=cls.unsafe_git_clone_options)
14631464

@@ -1633,6 +1634,7 @@ def archive(
16331634
treeish: Optional[str] = None,
16341635
prefix: Optional[str] = None,
16351636
allow_unsafe_options: bool = False,
1637+
allow_unsafe_protocols: bool = False,
16361638
**kwargs: Any,
16371639
) -> Repo:
16381640
"""Archive the tree at the given revision.
@@ -1656,7 +1658,10 @@ def archive(
16561658
or a list or tuple of multiple paths.
16571659
16581660
:param allow_unsafe_options:
1659-
Allow unsafe options in ``kwargs``, like ``--exec`` or ``--upload-pack``.
1661+
Allow unsafe options, like ``--exec`` or ``--output``.
1662+
1663+
:param allow_unsafe_protocols:
1664+
Allow unsafe protocols to be used in ``remote``, like ``ext``.
16601665
16611666
:raise git.exc.GitCommandError:
16621667
If something went wrong.
@@ -1668,8 +1673,14 @@ def archive(
16681673
treeish = self.head.commit
16691674
if prefix and "prefix" not in kwargs:
16701675
kwargs["prefix"] = prefix
1676+
remote = kwargs.get("remote")
1677+
if not allow_unsafe_protocols and remote:
1678+
Git.check_unsafe_protocols(str(remote))
16711679
if not allow_unsafe_options:
1672-
Git.check_unsafe_options(options=list(kwargs.keys()), unsafe_options=self.unsafe_git_archive_options)
1680+
Git.check_unsafe_options(
1681+
options=Git._option_candidates([], kwargs),
1682+
unsafe_options=self.unsafe_git_archive_options,
1683+
)
16731684
kwargs["output_stream"] = ostream
16741685
path = kwargs.pop("path", [])
16751686
path = cast(Union[PathLike, List[PathLike], Tuple[PathLike, ...]], path)

test/test_remote.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import random
1010
import sys
1111
import tempfile
12-
from unittest import skipIf
12+
from unittest import mock, skipIf
1313

1414
import pytest
1515

@@ -1023,6 +1023,11 @@ def test_ls_remote_unsafe_options(self, rw_repo):
10231023
with self.assertRaises(UnsafeOptionError):
10241024
rw_repo.git.ls_remote("--upload-pack", "touch", ".")
10251025

1026+
def test_ls_remote_allows_operand_named_like_unsafe_option(self):
1027+
with mock.patch.object(Git, "_call_process") as git:
1028+
Git().ls_remote("upload-pack")
1029+
git.assert_called_once()
1030+
10261031
@with_rw_repo("HEAD")
10271032
def test_ls_remote_unsafe_options_allowed(self, rw_repo):
10281033
with tempfile.TemporaryDirectory() as tdir:

test/test_repo.py

Lines changed: 40 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
Tree,
3939
)
4040
from git.exc import UnsafeOptionError
41+
from git.exc import UnsafeProtocolError
4142
from git.exc import BadObject
4243
from git.repo.fun import touch
4344
from git.util import bin_to_hex, cwd, cygpath, join_path_native, rmfile, rmtree
@@ -424,11 +425,35 @@ def test_archive(self):
424425
os.remove(stream.name) # Do it this way so we can inspect the file on failure.
425426

426427
def test_archive_rejects_unsafe_options(self):
427-
with tempfile.NamedTemporaryFile("w", suffix="-unsafe-option") as output_marker:
428+
with tempfile.TemporaryDirectory() as tdir:
429+
output_marker = osp.join(tdir, "pwn")
428430
with self.assertRaises(UnsafeOptionError):
429-
self.rorepo.archive(io.BytesIO(), "0.1.6", exec=f"touch {output_marker.name}")
431+
self.rorepo.archive(io.BytesIO(), "0.1.6", exec=f"touch {output_marker}")
432+
assert not osp.exists(output_marker)
430433
with self.assertRaises(UnsafeOptionError):
431-
self.rorepo.archive(io.BytesIO(), "0.1.6", output=f"touch {output_marker.name}")
434+
self.rorepo.archive(io.BytesIO(), "0.1.6", output=output_marker)
435+
assert not osp.exists(output_marker)
436+
437+
def test_archive_rejects_unsafe_remote_protocol(self):
438+
with tempfile.TemporaryDirectory() as tdir:
439+
output_marker = osp.join(tdir, "pwn")
440+
with self.assertRaises(UnsafeProtocolError):
441+
self.rorepo.archive(io.BytesIO(), "HEAD", remote=f"ext::sh -c touch% {output_marker}")
442+
assert not osp.exists(output_marker)
443+
444+
def test_archive_preserves_positional_allow_unsafe_options(self):
445+
with mock.patch.object(Git, "_call_process") as git:
446+
self.rorepo.archive(io.BytesIO(), "HEAD", None, True, exec="git-upload-archive")
447+
git.assert_called_once()
448+
449+
def test_archive_accepts_stringifiable_remote(self):
450+
class StringifiableRemote:
451+
def __str__(self):
452+
return "origin"
453+
454+
with mock.patch.object(Git, "_call_process") as git:
455+
self.rorepo.archive(io.BytesIO(), "HEAD", remote=StringifiableRemote())
456+
git.assert_called_once()
432457

433458
def test_iter_commits_rejects_unsafe_revision(self):
434459
with tempfile.TemporaryDirectory() as tdir:
@@ -486,19 +511,25 @@ def test_blame_real(self):
486511
assert nml, "There should at least be one blame commit that contains multiple lines"
487512

488513
def test_blame_rejects_unsafe_revision(self):
489-
with tempfile.NamedTemporaryFile("w", suffix="-unsafe-option") as output_marker:
514+
with tempfile.TemporaryDirectory() as tdir:
515+
output_marker = osp.join(tdir, "pwn")
490516
with self.assertRaises(UnsafeOptionError):
491-
self.rorepo.blame(f"--output={output_marker.name}", "README.md")
517+
self.rorepo.blame(f"--output={output_marker}", "README.md")
518+
assert not osp.exists(output_marker)
492519

493520
def test_blame_rejects_unsafe_options(self):
494-
with tempfile.NamedTemporaryFile("w", suffix="-unsafe-option") as output_marker:
521+
with tempfile.TemporaryDirectory() as tdir:
522+
output_marker = osp.join(tdir, "pwn")
495523
with self.assertRaises(UnsafeOptionError):
496-
self.rorepo.blame("HEAD", "README.md", output=f"touch {output_marker.name}")
524+
self.rorepo.blame("HEAD", "README.md", output=output_marker)
525+
assert not osp.exists(output_marker)
497526

498527
def test_blame_rejects_unsafe_rev_opts(self):
499-
with tempfile.NamedTemporaryFile("w", suffix="-unsafe-option") as output_marker:
528+
with tempfile.TemporaryDirectory() as tdir:
529+
output_marker = osp.join(tdir, "pwn")
500530
with self.assertRaises(UnsafeOptionError):
501-
self.rorepo.blame("HEAD", "README.md", rev_opts=[f"--output={output_marker.name}"])
531+
self.rorepo.blame("HEAD", "README.md", rev_opts=(f"--output={output_marker}",))
532+
assert not osp.exists(output_marker)
502533

503534
@mock.patch.object(Git, "_call_process")
504535
def test_blame_incremental(self, git):

0 commit comments

Comments
 (0)