Skip to content

Commit fd7027c

Browse files
JingHao-Leoncodex
andcommitted
fix(config): decode escapes in unquoted config values
Git decodes backslash escapes in unquoted configuration values exactly as it does in quoted ones, 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 changed meaning 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. 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 fd7027c

3 files changed

Lines changed: 32 additions & 5 deletions

File tree

‎git/config.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,6 @@ def parse_value(value: str) -> str:
606606
# next line is appended before the complete value is
607607
# parsed. An even number means the last backslash is
608608
# escaped and the value ends there.
609-
continued = False
610609
while True:
611610
if not is_line_continuation(optval):
612611
break
@@ -620,9 +619,10 @@ def parse_value(value: str) -> str:
620619
while joined.endswith("\n") or joined.endswith("\r"):
621620
joined = joined[:-1]
622621
optval = optval[:-1] + joined
623-
continued = True
624-
if continued:
625-
optval = parse_value(optval)
622+
# Git decodes escape sequences in unquoted values exactly as
623+
# it does in quoted ones, so a value that needs no line
624+
# continuation is parsed the same way.
625+
optval = parse_value(optval)
626626
elif quote_open:
627627
# Opens quoting and does not close: appears to start multi-line quoting.
628628
is_multi_line = True
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
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

‎test/test_config.py‎

Lines changed: 18 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,23 @@ 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+
"""Interpret Git's escapes in unquoted values, which git decodes just as it
1214+
does for quoted ones."""
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+
# Values without escapes keep parsing as before.
1224+
self.assertEqual(cr.get("custom", "ordinary"), "hello world")
1225+
self.assertEqual(cr.get("custom", "withcolon"), "host:port")
1226+
self.assertEqual(cr.get("custom", "withequals"), "key=value")
1227+
self.assertEqual(cr.get("custom", "withhash"), "value")
1228+
12121229
def test_get_values_works_without_requiring_any_other_calls_first(self):
12131230
file_obj = self._to_memcache(fixture_path("git_config_multiple"))
12141231
cr = GitConfigParser(file_obj, read_only=True)

0 commit comments

Comments
 (0)