Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 33 additions & 5 deletions git/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,13 @@
VALID_CONFIG_OPTION_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
"""Pattern for option names that can be written without changing config syntax."""

UNQUOTED_ESCAPES = {"b": "\b", "n": "\n", "t": "\t", '"': '"', "\\": "\\"}
"""Escape sequences git recognizes in an unquoted value.

Any other backslash escape is an error for git, which is why a value holding one is
left undecoded rather than rejected.
"""


class MetaParserBuilder(abc.ABCMeta): # noqa: B024
"""Utility class wrapping base-class methods into decorators that assure read-only
Expand Down Expand Up @@ -530,10 +537,9 @@ def parse_value(value: str) -> str:
parsed: List[str] = []
whitespace: List[str] = []
quoted = escaped = False
escapes = {"b": "\b", "n": "\n", "t": "\t", '"': '"', "\\": "\\"}
for char in value:
if escaped:
parsed.append(escapes.get(char, "\\" + char))
parsed.append(UNQUOTED_ESCAPES.get(char, "\\" + char))
escaped = False
continue
if char.isspace() and not quoted:
Expand All @@ -552,6 +558,26 @@ def parse_value(value: str) -> str:
parsed.append(char)
return "".join(parsed)

def escapes_are_known(value: str) -> bool:
"""Whether every backslash in `value` introduces an escape git recognises.

Git rejects an unquoted value carrying an unknown escape outright. GitPython
tolerates such values, because that is what lets a Windows path such as
``C:\\Users\\me`` stay literal, so a value is decoded only when every one
of its backslashes is part of a known escape. Decoding part of a value would
otherwise turn the ``\\t`` of ``\\Temp\\test`` into a tab while leaving the
``\\U`` of ``\\Users`` alone.
"""
index = value.find("\\")
while index >= 0:
# A trailing backslash has nothing to escape and git drops it, so
# only a backslash followed by an unknown escape disqualifies the
# value from being decoded.
if index + 1 < len(value) and value[index + 1] not in UNQUOTED_ESCAPES:
return False
index = value.find("\\", index + 2)
return True

while True:
# We assume to read binary!
raw_line = fp.readline()
Expand Down Expand Up @@ -606,7 +632,6 @@ def parse_value(value: str) -> str:
# next line is appended before the complete value is
# parsed. An even number means the last backslash is
# escaped and the value ends there.
continued = False
while True:
if not is_line_continuation(optval):
break
Expand All @@ -620,8 +645,11 @@ def parse_value(value: str) -> str:
while joined.endswith("\n") or joined.endswith("\r"):
joined = joined[:-1]
optval = optval[:-1] + joined
continued = True
if continued:
# Git decodes the standard escape sequences in unquoted
# values, but only a value in which every backslash is part
# of a known escape is decoded, so that a Windows path keeps
# its backslashes instead of losing them one escape at a time.
if escapes_are_known(optval):
optval = parse_value(optval)
elif quote_open:
# Opens quoting and does not close: appears to start multi-line quoting.
Expand Down
12 changes: 12 additions & 0 deletions test/fixtures/git_config_unquoted_escapes
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
[custom]
hasnewline = first\nsecond
hastab = first\tsecond
hasbackslash = foo\\bar
hasquote = ab\"cd
hastrailingbackslash = word\\
ordinary = hello world
withcolon = host:port
withequals = key=value
withhash = value#notcomment
hasunknownescape = foo\qbar
haswindowspath = C:\Users\RUNNER~1\AppData\Local\Temp\test_foo
26 changes: 25 additions & 1 deletion test/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ def test_backslash_line_continuation(self):
(b"[a]\n\tk = one\\\n two ; ignored\n", "one two"),
(b'[a]\n\tk = one\\\n "two"\n', "one two"),
(b"[a]\n\tk = one\\\n two\\tthree\n", "one two\tthree"),
(b"[a]\n\tk = val\\\\\n next\n", "val\\\\"),
(b"[a]\n\tk = val\\\\\n next\n", "val\\"),
(b"[a]\n\tk = end\\\n", "end"),
(b"[alias]\n\tco = checkout \\\n\t\t-v\n", "checkout \t\t-v"),
]
Expand Down Expand Up @@ -1209,6 +1209,30 @@ def test_config_with_quotes_containing_escapes(self):
# Cases without quotes should still parse correctly even after those, too.
self.assertEqual(cr.get("custom", "unquoted"), "good evening")

def test_config_with_unquoted_escapes(self):
"""Decode git's escape sequences in unquoted values, but only when every
backslash in the value is part of a known escape."""
cr = GitConfigParser(fixture_path("git_config_unquoted_escapes"), read_only=True)

self.assertEqual(cr.get("custom", "hasnewline"), "first\nsecond")
self.assertEqual(cr.get("custom", "hastab"), "first\tsecond")
self.assertEqual(cr.get("custom", "hasbackslash"), R"foo\bar")
self.assertEqual(cr.get("custom", "hasquote"), 'ab"cd')
self.assertEqual(cr.get("custom", "hastrailingbackslash"), "word\\")

# Git rejects an unknown escape rather than decoding it. GitPython keeps
# such a value literal instead of rejecting it, which is what keeps a
# Windows path usable, so no backslash in one may be decoded: doing so
# would turn the \t of \Temp\test into a tab while leaving \U alone.
self.assertEqual(cr.get("custom", "hasunknownescape"), R"foo\qbar")
self.assertEqual(cr.get("custom", "haswindowspath"), R"C:\Users\RUNNER~1\AppData\Local\Temp\test_foo")

# Values without escapes keep parsing as before.
self.assertEqual(cr.get("custom", "ordinary"), "hello world")
self.assertEqual(cr.get("custom", "withcolon"), "host:port")
self.assertEqual(cr.get("custom", "withequals"), "key=value")
self.assertEqual(cr.get("custom", "withhash"), "value")

def test_get_values_works_without_requiring_any_other_calls_first(self):
file_obj = self._to_memcache(fixture_path("git_config_multiple"))
cr = GitConfigParser(file_obj, read_only=True)
Expand Down
Loading