Skip to content

Commit 4b9afe9

Browse files
authored
Merge pull request #2213 from gitpython-developers/config-sanitizer-follow-up
Preserve Git config escape semantics
2 parents 52a6cba + eefa7e4 commit 4b9afe9

2 files changed

Lines changed: 102 additions & 15 deletions

File tree

git/config.py

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -462,7 +462,8 @@ def string_decode(v: str) -> str:
462462
v = v[:-1]
463463
# END cut trailing escapes to prevent decode error
464464

465-
return v.encode(defenc).decode("unicode_escape")
465+
escapes = {"b": "\b", "n": "\n", "r": "\r", "t": "\t", '"': '"', "\\": "\\"}
466+
return re.sub(r"\\(.)", lambda match: escapes.get(match.group(1), match.group(0)), v)
466467

467468
# END string_decode
468469

@@ -517,10 +518,12 @@ def string_decode(v: str) -> str:
517518
# Opens quoting and does not close: appears to start multi-line quoting.
518519
is_multi_line = True
519520
optval = string_decode(optval[1:])
520-
elif optval.find("\\", 1, -1) == -1 and optval.find('"', 1, -1) == -1:
521-
# Opens and closes quoting. Single line, and all we need is quote removal.
522-
optval = optval[1:-1]
523-
# TODO: Handle other quoted content, especially well-formed backslash escapes.
521+
elif re.search(r'(?:^|[^\\])(?:\\\\)*"', optval[1:-1]):
522+
# Preserve malformed values containing unescaped quotes.
523+
pass
524+
else:
525+
# Opens and closes quoting.
526+
optval = string_decode(optval[1:-1])
524527

525528
# Preserves multiple values for duplicate optnames.
526529
cursect.add(optname, optval)
@@ -706,7 +709,7 @@ def write_section(name: str, section_dict: _OMD) -> None:
706709

707710
for v in values:
708711
value = self._value_to_string(v)
709-
if any(char in value for char in '\n\t\b\\"'):
712+
if any(char in value for char in '\n\t\b\\"#;') or value[:1].isspace() or value[-1:].isspace():
710713
value = value.replace("\\", "\\\\").replace('"', '\\"')
711714
value = '"%s\\\n"' % value.replace("\n", "\\n").replace("\t", "\\t").replace("\b", "\\b")
712715
fp.write(("\t%s = %s\n" % (key, value)).encode(defenc))
@@ -768,6 +771,20 @@ def write(self) -> None:
768771
return
769772
# END stop if we have include files
770773

774+
sections: List[_OMD] = [self._defaults]
775+
section: _OMD
776+
stored_section: _OMD
777+
values: List[Any]
778+
raw_value: Any
779+
for _, stored_section in self._sections.items():
780+
sections.append(stored_section)
781+
for section in sections:
782+
for key, values in section.items_all():
783+
if key != "__name__":
784+
for raw_value in values:
785+
if "\r" in self._value_to_string(raw_value) or "\x00" in self._value_to_string(raw_value):
786+
raise ValueError("Git config values must not contain CR or NUL")
787+
771788
fp = self._file_or_files
772789

773790
# We have a physical file on disk, so get a lock.

test/test_config.py

Lines changed: 79 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import pytest
1515

1616
from git import GitConfigParser
17+
from git.compat import defenc
1718
from git.config import _OMD, cp
1819
from git.util import cwd, rmfile
1920
from test.lib import SkipTest, TestCase, fixture_path, with_rw_directory
@@ -170,7 +171,16 @@ def test_rewriting_multiline_value_does_not_create_option(self, rw_dir):
170171
@with_rw_directory
171172
def test_writer_escapes_special_characters_without_newline(self, rw_dir):
172173
config_path = osp.join(rw_dir, "config")
173-
values = {"tab": "\tvalue\t", "backspace": "a\bb", "quote": 'a"b', "backslash": "a\\qb"}
174+
values = {
175+
"tab": "\tvalue\t",
176+
"backspace": "a\bb",
177+
"quote": 'a"b',
178+
"backslash": "a\\qb",
179+
"hash": "value#fragment",
180+
"semicolon": "value;fragment",
181+
"leading": " value",
182+
"trailing": "value ",
183+
}
174184

175185
with GitConfigParser(config_path, read_only=False) as git_config:
176186
for key, value in values.items():
@@ -185,11 +195,72 @@ def test_writer_escapes_special_characters_without_newline(self, rw_dir):
185195
stdout=subprocess.PIPE,
186196
check=True,
187197
).stdout,
188-
value.encode() + b"\n",
198+
value.encode(defenc) + b"\n",
189199
)
190200
with open(config_path, "rb") as config_file:
191201
self.assertNotIn(b"\x08", config_file.read())
192202

203+
@with_rw_directory
204+
def test_writer_preserves_escaped_and_non_ascii_values_safely(self, rw_dir):
205+
config_path = osp.join(rw_dir, "config")
206+
with open(config_path, "wb") as config_file:
207+
config_file.write(
208+
(
209+
'[section]\nnewline = "first\\nsecond"\nquote = "a\\"b"\nbackslash = "a\\\\b"\n'
210+
'unicode = "café\\\\path"\n'
211+
).encode(defenc)
212+
)
213+
214+
with GitConfigParser(config_path, read_only=False) as config:
215+
config.set_value("unrelated", "key", "value")
216+
217+
expected = {
218+
"newline": "first\nsecond",
219+
"quote": 'a"b',
220+
"backslash": "a\\b",
221+
"unicode": "café\\path",
222+
}
223+
with GitConfigParser(config_path, read_only=True) as config:
224+
for key, value in expected.items():
225+
self.assertEqual(
226+
config.get_value("section", key),
227+
value,
228+
"GitPython should preserve values when rewriting unrelated entries",
229+
)
230+
self.assertEqual(
231+
subprocess.run(
232+
["git", "config", "--file", config_path, "--get", "section.%s" % key],
233+
stdout=subprocess.PIPE,
234+
check=True,
235+
).stdout,
236+
value.encode(defenc) + b"\n",
237+
"git should read rewritten values with the same semantics",
238+
)
239+
240+
with open(config_path, "rb") as config_file:
241+
contents = config_file.read()
242+
self.assertNotIn(b"\r", contents, "the writer should never emit carriage returns")
243+
self.assertNotIn(b"\x00", contents, "the writer should never emit NUL bytes")
244+
245+
for name, value in (("return", b"first\\rsecond"), ("nul", b"first\x00second")):
246+
unsafe_path = osp.join(rw_dir, "%s-config" % name)
247+
unsafe_contents = b'[section]\nvalue = "' + value + b'"\n'
248+
with open(unsafe_path, "wb") as config_file:
249+
config_file.write(unsafe_contents)
250+
with self.assertRaisesRegex(
251+
ValueError,
252+
"CR or NUL",
253+
msg="unsafe existing values should abort rewrites",
254+
):
255+
with GitConfigParser(unsafe_path, read_only=False) as config:
256+
config.set_value("unrelated", "key", "value")
257+
with open(unsafe_path, "rb") as config_file:
258+
self.assertEqual(
259+
config_file.read(),
260+
unsafe_contents,
261+
"rejected rewrites should leave the original file unchanged",
262+
)
263+
193264
@with_rw_directory
194265
def test_set_value_rejects_config_injection(self, rw_dir):
195266
config_path = osp.join(rw_dir, "config")
@@ -745,15 +816,14 @@ def test_config_with_quotes_with_whitespace_outside_value(self):
745816
self.assertEqual(cr.get("init", "defaultBranch"), "trunk")
746817

747818
def test_config_with_quotes_containing_escapes(self):
748-
"""For now just suppress quote removal. But it would be good to interpret most of these."""
819+
"""Interpret Git's quoted escapes without changing malformed values."""
749820
cr = GitConfigParser(fixture_path("git_config_with_quotes_escapes"), read_only=True)
750821

751-
# These can eventually be supported by substituting the represented character.
752-
self.assertEqual(cr.get("custom", "hasnewline"), R'"first\nsecond"')
753-
self.assertEqual(cr.get("custom", "hasbackslash"), R'"foo\\bar"')
754-
self.assertEqual(cr.get("custom", "hasquote"), R'"ab\"cd"')
755-
self.assertEqual(cr.get("custom", "hastrailingbackslash"), R'"word\\"')
756-
self.assertEqual(cr.get("custom", "hasunrecognized"), R'"p\qrs"')
822+
self.assertEqual(cr.get("custom", "hasnewline"), "first\nsecond")
823+
self.assertEqual(cr.get("custom", "hasbackslash"), R"foo\bar")
824+
self.assertEqual(cr.get("custom", "hasquote"), 'ab"cd')
825+
self.assertEqual(cr.get("custom", "hastrailingbackslash"), "word\\")
826+
self.assertEqual(cr.get("custom", "hasunrecognized"), R"p\qrs")
757827

758828
# It is less obvious whether and what to eventually do with this.
759829
self.assertEqual(cr.get("custom", "hasunescapedquotes"), '"ab"cd"e"')

0 commit comments

Comments
 (0)