Skip to content

Commit 983e182

Browse files
JingHao-Leoncodex
andcommitted
fix(config): decode escapes in unquoted config values
Git decodes the standard escape sequences in unquoted config values, but `GitConfigParser._read` only called `parse_value()` when a value continued onto a following line. A single-line unquoted value therefore kept its escapes literal, so `b = first\nsecond` read back as `first\nsecond` rather than `first<LF>second`. The same value also decoded differently depending only on whether it was wrapped: `b = first\nsecond \` continued onto a second line already went through `parse_value()` and was decoded. The missing decode made writes unsafe. The parsed value held no control character, so the writer's CR/LF/NUL guard did not trigger, and saving an unrelated key emitted a value that git then read differently than before. With the escape decoded, the existing guard rejects the value instead of corrupting the file. A value is decoded only when every backslash in it introduces a known escape. Git rejects an unknown escape outright, so GitPython never had to handle one; it tolerates them instead, which is what keeps a Windows path such as `C:\Users\me` usable. Decoding unconditionally would have broken that: the `\t` of `\Temp\test_foo` would become a tab while the `\U` of `\Users` stayed literal, mangling the path. Guarding on the whole value decodes `a\tb` and `foo\\bar` as git does and leaves the path alone. An existing case in `test_backslash_line_continuation` asserted the undecoded spelling `val\\` for `k = val\\` followed by a newline. Git yields `val\` there, since the second backslash escapes the first, so that expectation is corrected to match. Checked against git 2.50.1 (Apple Git-155). Co-Authored-By: Codex <codex@openai.com>
1 parent 9d3aff0 commit 983e182

3 files changed

Lines changed: 70 additions & 6 deletions

File tree

‎git/config.py‎

Lines changed: 33 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,13 @@
6969
VALID_CONFIG_OPTION_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$")
7070
"""Pattern for option names that can be written without changing config syntax."""
7171

72+
UNQUOTED_ESCAPES = {"b": "\b", "n": "\n", "t": "\t", '"': '"', "\\": "\\"}
73+
"""Escape sequences git recognizes in an unquoted value.
74+
75+
Any other backslash escape is an error for git, which is why a value holding one is
76+
left undecoded rather than rejected.
77+
"""
78+
7279

7380
class MetaParserBuilder(abc.ABCMeta): # noqa: B024
7481
"""Utility class wrapping base-class methods into decorators that assure read-only
@@ -530,10 +537,9 @@ def parse_value(value: str) -> str:
530537
parsed: List[str] = []
531538
whitespace: List[str] = []
532539
quoted = escaped = False
533-
escapes = {"b": "\b", "n": "\n", "t": "\t", '"': '"', "\\": "\\"}
534540
for char in value:
535541
if escaped:
536-
parsed.append(escapes.get(char, "\\" + char))
542+
parsed.append(UNQUOTED_ESCAPES.get(char, "\\" + char))
537543
escaped = False
538544
continue
539545
if char.isspace() and not quoted:
@@ -552,6 +558,26 @@ def parse_value(value: str) -> str:
552558
parsed.append(char)
553559
return "".join(parsed)
554560

561+
def escapes_are_known(value: str) -> bool:
562+
"""Whether every backslash in `value` introduces an escape git recognises.
563+
564+
Git rejects an unquoted value carrying an unknown escape outright. GitPython
565+
tolerates such values, because that is what lets a Windows path such as
566+
``C:\\Users\\me`` stay literal, so a value is decoded only when every one
567+
of its backslashes is part of a known escape. Decoding part of a value would
568+
otherwise turn the ``\\t`` of ``\\Temp\\test`` into a tab while leaving the
569+
``\\U`` of ``\\Users`` alone.
570+
"""
571+
index = value.find("\\")
572+
while index >= 0:
573+
# A trailing backslash has nothing to escape and git drops it, so
574+
# only a backslash followed by an unknown escape disqualifies the
575+
# value from being decoded.
576+
if index + 1 < len(value) and value[index + 1] not in UNQUOTED_ESCAPES:
577+
return False
578+
index = value.find("\\", index + 2)
579+
return True
580+
555581
while True:
556582
# We assume to read binary!
557583
raw_line = fp.readline()
@@ -606,7 +632,6 @@ def parse_value(value: str) -> str:
606632
# next line is appended before the complete value is
607633
# parsed. An even number means the last backslash is
608634
# escaped and the value ends there.
609-
continued = False
610635
while True:
611636
if not is_line_continuation(optval):
612637
break
@@ -620,8 +645,11 @@ def parse_value(value: str) -> str:
620645
while joined.endswith("\n") or joined.endswith("\r"):
621646
joined = joined[:-1]
622647
optval = optval[:-1] + joined
623-
continued = True
624-
if continued:
648+
# Git decodes the standard escape sequences in unquoted
649+
# values, but only a value in which every backslash is part
650+
# of a known escape is decoded, so that a Windows path keeps
651+
# its backslashes instead of losing them one escape at a time.
652+
if escapes_are_known(optval):
625653
optval = parse_value(optval)
626654
elif quote_open:
627655
# Opens quoting and does not close: appears to start multi-line quoting.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
[custom]
2+
hasnewline = first\nsecond
3+
hastab = first\tsecond
4+
hasbackslash = foo\\bar
5+
hasquote = ab\"cd
6+
hastrailingbackslash = word\\
7+
ordinary = hello world
8+
withcolon = host:port
9+
withequals = key=value
10+
withhash = value#notcomment
11+
hasunknownescape = foo\qbar
12+
haswindowspath = C:\Users\RUNNER~1\AppData\Local\Temp\test_foo

‎test/test_config.py‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,7 +365,7 @@ def test_backslash_line_continuation(self):
365365
(b"[a]\n\tk = one\\\n two ; ignored\n", "one two"),
366366
(b'[a]\n\tk = one\\\n "two"\n', "one two"),
367367
(b"[a]\n\tk = one\\\n two\\tthree\n", "one two\tthree"),
368-
(b"[a]\n\tk = val\\\\\n next\n", "val\\\\"),
368+
(b"[a]\n\tk = val\\\\\n next\n", "val\\"),
369369
(b"[a]\n\tk = end\\\n", "end"),
370370
(b"[alias]\n\tco = checkout \\\n\t\t-v\n", "checkout \t\t-v"),
371371
]
@@ -1209,6 +1209,30 @@ def test_config_with_quotes_containing_escapes(self):
12091209
# Cases without quotes should still parse correctly even after those, too.
12101210
self.assertEqual(cr.get("custom", "unquoted"), "good evening")
12111211

1212+
def test_config_with_unquoted_escapes(self):
1213+
"""Decode git's escape sequences in unquoted values, but only when every
1214+
backslash in the value is part of a known escape."""
1215+
cr = GitConfigParser(fixture_path("git_config_unquoted_escapes"), read_only=True)
1216+
1217+
self.assertEqual(cr.get("custom", "hasnewline"), "first\nsecond")
1218+
self.assertEqual(cr.get("custom", "hastab"), "first\tsecond")
1219+
self.assertEqual(cr.get("custom", "hasbackslash"), R"foo\bar")
1220+
self.assertEqual(cr.get("custom", "hasquote"), 'ab"cd')
1221+
self.assertEqual(cr.get("custom", "hastrailingbackslash"), "word\\")
1222+
1223+
# Git rejects an unknown escape rather than decoding it. GitPython keeps
1224+
# such a value literal instead of rejecting it, which is what keeps a
1225+
# Windows path usable, so no backslash in one may be decoded: doing so
1226+
# would turn the \t of \Temp\test into a tab while leaving \U alone.
1227+
self.assertEqual(cr.get("custom", "hasunknownescape"), R"foo\qbar")
1228+
self.assertEqual(cr.get("custom", "haswindowspath"), R"C:\Users\RUNNER~1\AppData\Local\Temp\test_foo")
1229+
1230+
# Values without escapes keep parsing as before.
1231+
self.assertEqual(cr.get("custom", "ordinary"), "hello world")
1232+
self.assertEqual(cr.get("custom", "withcolon"), "host:port")
1233+
self.assertEqual(cr.get("custom", "withequals"), "key=value")
1234+
self.assertEqual(cr.get("custom", "withhash"), "value")
1235+
12121236
def test_get_values_works_without_requiring_any_other_calls_first(self):
12131237
file_obj = self._to_memcache(fixture_path("git_config_multiple"))
12141238
cr = GitConfigParser(file_obj, read_only=True)

0 commit comments

Comments
 (0)