Skip to content

Commit a89ad71

Browse files
codexByron
authored andcommitted
fix: restore config parser compatibility
The Python package matrix failed across POSIX runners because a submodule test wrote dotted option names inside a submodule section, syntax that Git rejects and the new parser correctly disallows. Write the test identity through the submodule repository config instead. Windows matrix jobs additionally failed while parsing manually assembled include files containing unescaped native paths. Serialize those test values and conditional subsection patterns with Git-compatible quoting so backslashes remain data rather than invalid escape prefixes. Match Git value-setter behavior by accepting CR and LF, which the canonical writer can safely quote and round-trip, while continuing to reject NUL in setters and parsed files. The newline injection regression now verifies escaped data and absence of an injected section.
1 parent f86acf5 commit a89ad71

3 files changed

Lines changed: 50 additions & 36 deletions

File tree

git/config.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,10 @@
7777
"""
7878

7979
UNSAFE_CONFIG_CHARS_RE = re.compile(r"[\r\n\x00]")
80-
"""Characters that cannot be safely written in config names or values."""
80+
"""Characters that cannot be safely written in config names."""
81+
82+
UNSAFE_CONFIG_VALUE_CHARS_RE = re.compile(r"\x00")
83+
"""Characters that cannot be represented in Git config values."""
8184

8285

8386
def _escape_section_subsection(value: str) -> str:
@@ -187,6 +190,8 @@ def _parse_value(self, line_number: int) -> str:
187190
if quoted:
188191
self._error(line_number)
189192
return "".join(value)
193+
if char == "\x00":
194+
self._error(line_number)
190195
if comment:
191196
continue
192197
if self._is_space(char) and not quoted:
@@ -984,8 +989,8 @@ def _value_to_string(self, value: Union[str, bytes, int, float, bool]) -> str:
984989

985990
def _value_to_string_safe(self, value: Union[str, bytes, int, float, bool]) -> str:
986991
value_str = self._value_to_string(value)
987-
if UNSAFE_CONFIG_CHARS_RE.search(value_str):
988-
raise ValueError("Git config values must not contain CR, LF, or NUL")
992+
if UNSAFE_CONFIG_VALUE_CHARS_RE.search(value_str):
993+
raise ValueError("Git config values must not contain NUL")
989994
return value_str
990995

991996
def _assure_config_name_safe(self, name: "cp._SectionName", label: str) -> None:

test/test_config.py

Lines changed: 39 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import pytest
1414

1515
from git import Git, GitConfigParser
16-
from git.config import _OMD, cp
16+
from git.config import _OMD, _escape_config_value, _escape_section_subsection, cp
1717
from git.util import rmfile
1818

1919
from test.lib import SkipTest, TestCase, fixture_path, with_rw_directory
@@ -169,6 +169,7 @@ def test_git_parser_rejects_syntax_rejected_by_git(self):
169169
b"[core]\nkey: value\n",
170170
b'[core]\nkey = "unterminated\n',
171171
b'[core]\nkey = "bad\\q"\n',
172+
b'[core]\nkey = "bad\x00value"\n',
172173
)
173174
for config_content in invalid_configs:
174175
with self.subTest(config_content=config_content):
@@ -236,16 +237,15 @@ def test_writer_escapes_quoted_subsection_names(self, rw_dir):
236237
self.assertEqual(Git().config("--file", config_path, "--get", 'submodule.docs"archive\\part].path'), "docs")
237238

238239
@with_rw_directory
239-
def test_set_value_rejects_config_injection(self, rw_dir):
240+
def test_set_value_escapes_config_injection(self, rw_dir):
240241
config_path = osp.join(rw_dir, "config")
241242
payload = "foo\n[core]\nhooksPath=/tmp/hooks"
242243

243244
with GitConfigParser(config_path, read_only=False) as git_config:
244-
with pytest.raises(ValueError, match="CR, LF, or NUL"):
245-
git_config.set_value("user", "name", payload)
245+
git_config.set_value("user", "name", payload)
246246

247247
with GitConfigParser(config_path, read_only=True) as git_config:
248-
self.assertFalse(git_config.has_section("user"))
248+
self.assertEqual(git_config.get_value("user", "name"), payload)
249249
self.assertFalse(git_config.has_section("core"))
250250

251251
@with_rw_directory
@@ -321,24 +321,26 @@ def test_writer_rejects_unquoted_section_terminators(self, rw_dir):
321321
self.assertFalse(git_config.has_section("other"), "an unsafe section name injected an [other] section")
322322

323323
@with_rw_directory
324-
def test_set_and_add_value_reject_unsafe_value_characters(self, rw_dir):
324+
def test_set_and_add_value_match_git_control_character_handling(self, rw_dir):
325325
config_path = osp.join(rw_dir, "config")
326-
bad_values = ("foo\rbar", "foo\nbar", "foo\x00bar", b"foo\nbar")
327326

328327
with GitConfigParser(config_path, read_only=False) as git_config:
329328
git_config.add_section("user")
330-
for bad_value in bad_values:
331-
with pytest.raises(ValueError, match="CR, LF, or NUL"):
332-
git_config.set("user", "name", bad_value)
333-
with pytest.raises(ValueError, match="CR, LF, or NUL"):
334-
git_config.set_value("user", "name", bad_value)
335-
with pytest.raises(ValueError, match="CR, LF, or NUL"):
336-
git_config.add_value("user", "name", bad_value)
329+
git_config.set("user", "carriage-return", "foo\rbar")
330+
git_config.set_value("user", "line-feed", "foo\nbar")
331+
git_config.add_value("user", "bytes-line-feed", b"foo\nbar")
337332

338-
git_config.set_value("user", "name", "safe")
333+
for setter in (git_config.set, git_config.set_value, git_config.add_value):
334+
with pytest.raises(ValueError, match="must not contain NUL"):
335+
setter("user", "name", "foo\x00bar")
339336

340337
with GitConfigParser(config_path, read_only=True) as git_config:
341-
self.assertEqual(git_config.get_value("user", "name"), "safe")
338+
self.assertEqual(git_config.get_value("user", "carriage-return"), "foo\rbar")
339+
self.assertEqual(git_config.get_value("user", "line-feed"), "foo\nbar")
340+
self.assertEqual(git_config.get_value("user", "bytes-line-feed"), "foo\nbar")
341+
342+
self.assertEqual(Git().config("--file", config_path, "--get", "user.carriage-return"), "foo\rbar")
343+
self.assertEqual(Git().config("--file", config_path, "--get", "user.line-feed"), "foo\nbar")
342344

343345
def test_base(self):
344346
path_repo = fixture_path("git_config")
@@ -482,8 +484,8 @@ def test_multiple_include_paths_with_same_key(self, rw_dir):
482484
# We write it manually because set_value would overwrite the key.
483485
with open(fp_main, "w") as f:
484486
f.write("[include]\n")
485-
f.write(f"\tpath = {fp_inc1}\n")
486-
f.write(f"\tpath = {fp_inc2}\n")
487+
f.write(f"\tpath = {_escape_config_value(fp_inc1)}\n")
488+
f.write(f"\tpath = {_escape_config_value(fp_inc2)}\n")
487489

488490
with GitConfigParser(fp_main, read_only=True) as cr:
489491
# Both included files should be loaded.
@@ -509,8 +511,15 @@ def test_conditional_includes_from_git_dir(self, rw_dir):
509511
path2 = osp.join(rw_dir, "config2")
510512
template = '[includeIf "{}:{}"]\n path={}\n'
511513

514+
def include_config(condition, pattern):
515+
return template.format(
516+
condition,
517+
_escape_section_subsection(pattern),
518+
_escape_config_value(path2),
519+
)
520+
512521
with open(path1, "w") as stream:
513-
stream.write(template.format("gitdir", git_dir, path2))
522+
stream.write(include_config("gitdir", git_dir))
514523

515524
# Ensure that config is ignored if no repo is set.
516525
with GitConfigParser(path1) as config:
@@ -524,39 +533,39 @@ def test_conditional_includes_from_git_dir(self, rw_dir):
524533

525534
# Ensure that config is ignored if case is incorrect.
526535
with open(path1, "w") as stream:
527-
stream.write(template.format("gitdir", git_dir.upper(), path2))
536+
stream.write(include_config("gitdir", git_dir.upper()))
528537

529538
with GitConfigParser(path1, repo=repo) as config:
530539
assert not config._has_includes()
531540
assert config._included_paths() == []
532541

533542
# Ensure that config is included if case is ignored.
534543
with open(path1, "w") as stream:
535-
stream.write(template.format("gitdir/i", git_dir.upper(), path2))
544+
stream.write(include_config("gitdir/i", git_dir.upper()))
536545

537546
with GitConfigParser(path1, repo=repo) as config:
538547
assert config._has_includes()
539548
assert config._included_paths() == [("path", path2)]
540549

541550
# Ensure that config is included with path using glob pattern.
542551
with open(path1, "w") as stream:
543-
stream.write(template.format("gitdir", "**/repo1", path2))
552+
stream.write(include_config("gitdir", "**/repo1"))
544553

545554
with GitConfigParser(path1, repo=repo) as config:
546555
assert config._has_includes()
547556
assert config._included_paths() == [("path", path2)]
548557

549558
# Ensure that config is ignored if path is not matching git_dir.
550559
with open(path1, "w") as stream:
551-
stream.write(template.format("gitdir", "incorrect", path2))
560+
stream.write(include_config("gitdir", "incorrect"))
552561

553562
with GitConfigParser(path1, repo=repo) as config:
554563
assert not config._has_includes()
555564
assert config._included_paths() == []
556565

557566
# Ensure that config is included if path in hierarchy.
558567
with open(path1, "w") as stream:
559-
stream.write(template.format("gitdir", "target1/", path2))
568+
stream.write(include_config("gitdir", "target1/"))
560569

561570
with GitConfigParser(path1, repo=repo) as config:
562571
assert config._has_includes()
@@ -578,23 +587,23 @@ def test_conditional_includes_from_branch_name(self, rw_dir):
578587

579588
# Ensure that config is included is branch is correct.
580589
with open(path1, "w") as stream:
581-
stream.write(template.format("/foo/branch", path2))
590+
stream.write(template.format("/foo/branch", _escape_config_value(path2)))
582591

583592
with GitConfigParser(path1, repo=repo) as config:
584593
assert config._has_includes()
585594
assert config._included_paths() == [("path", path2)]
586595

587596
# Ensure that config is included is branch is incorrect.
588597
with open(path1, "w") as stream:
589-
stream.write(template.format("incorrect", path2))
598+
stream.write(template.format("incorrect", _escape_config_value(path2)))
590599

591600
with GitConfigParser(path1, repo=repo) as config:
592601
assert not config._has_includes()
593602
assert config._included_paths() == []
594603

595604
# Ensure that config is included with branch using glob pattern.
596605
with open(path1, "w") as stream:
597-
stream.write(template.format("/foo/**", path2))
606+
stream.write(template.format("/foo/**", _escape_config_value(path2)))
598607

599608
with GitConfigParser(path1, repo=repo) as config:
600609
assert config._has_includes()
@@ -630,23 +639,23 @@ def test_conditional_includes_remote_url(self, rw_dir):
630639

631640
# Ensure that config with hasconfig and full url is correct.
632641
with open(path1, "w") as stream:
633-
stream.write(template.format("https://github.com/foo/repo", path2))
642+
stream.write(template.format("https://github.com/foo/repo", _escape_config_value(path2)))
634643

635644
with GitConfigParser(path1, repo=repo) as config:
636645
assert config._has_includes()
637646
assert config._included_paths() == [("path", path2)]
638647

639648
# Ensure that config with hasconfig and incorrect url is incorrect.
640649
with open(path1, "w") as stream:
641-
stream.write(template.format("incorrect", path2))
650+
stream.write(template.format("incorrect", _escape_config_value(path2)))
642651

643652
with GitConfigParser(path1, repo=repo) as config:
644653
assert not config._has_includes()
645654
assert config._included_paths() == []
646655

647656
# Ensure that config with hasconfig and url using glob pattern is correct.
648657
with open(path1, "w") as stream:
649-
stream.write(template.format("**/**github.com*/**", path2))
658+
stream.write(template.format("**/**github.com*/**", _escape_config_value(path2)))
650659

651660
with GitConfigParser(path1, repo=repo) as config:
652661
assert config._has_includes()

test/test_submodule.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -987,10 +987,10 @@ def test_git_submodules_and_add_sm_with_new_commit(self, rwdir):
987987

988988
parent.index.commit("moved submodules")
989989

990-
with sm.config_writer() as writer:
991-
writer.set_value("user.email", "example@example.com")
992-
writer.set_value("user.name", "me")
993990
smm = sm.module()
991+
with smm.config_writer() as writer:
992+
writer.set_value("user", "email", "example@example.com")
993+
writer.set_value("user", "name", "me")
994994
fp = osp.join(smm.working_tree_dir, "empty-file")
995995
with open(fp, "w"):
996996
pass

0 commit comments

Comments
 (0)