Skip to content

Commit e5b960a

Browse files
authored
Merge pull request #2297 from JingHao-Leon/fix/config-unquoted-escape-parsing
fix(config): decode escapes in unquoted config values
2 parents a3b2f15 + 6d81c05 commit e5b960a

6 files changed

Lines changed: 124 additions & 10 deletions

File tree

‎AGENTS.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,10 @@ including the [Prevent agent impersonation](CONTRIBUTING.md#prevent-agent-impers
55
section governing agent identification and separation of unaltered user statements
66
when communicating through a person's account.
77

8+
[Prefer pull requests](CONTRIBUTING.md#prefer-pull-requests): a PR can serve as an
9+
issue with a fix attached. Do not create an issue solely to follow it immediately
10+
with a PR that fixes it; describe the problem in the PR instead.
11+
812
# Commit messages
913

1014
Follow Conventional Commits for every commit. Every commit must have a

‎CONTRIBUTING.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,16 @@ The following is a short step-by-step rundown of what one typically would do to
99
- Feel free to add yourself to AUTHORS file.
1010
- Create a pull request.
1111

12+
## Prefer pull requests
13+
14+
Pull requests are preferred when you have a fix or improvement to contribute: a PR
15+
can be treated as an issue with a fix attached. Describe the problem and proposed
16+
solution in the PR itself; a separate issue is not required.
17+
18+
Please avoid opening an issue solely to follow it immediately with a PR that fixes
19+
it. Open the PR directly instead. Issues are still welcome for problems without a
20+
fix or for changes that need discussion before implementation.
21+
1222
## Quality expectations
1323

1424
Contributions must be made with care and meet the quality bar of the surrounding code.

‎README.md‎

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -201,8 +201,12 @@ Please have a look at the [contributions file][contributing].
201201
- [Questions and Answers](http://stackexchange.com/filters/167317/gitpython)
202202
- Please post on Stack Overflow and use the `gitpython` tag
203203
- [Issue Tracker](https://github.com/gitpython-developers/GitPython/issues)
204-
- Post reproducible bugs and feature requests as a new issue.
205-
Please be sure to provide the following information if posting bugs:
204+
- Post reproducible bugs and feature requests as a new issue when you do not have
205+
a fix or need discussion before implementation. If you have a fix or improvement,
206+
[open a pull request directly](CONTRIBUTING.md#prefer-pull-requests) and describe
207+
the problem there; you do not need to create an issue first.
208+
Please be sure to provide the following information when reporting bugs in an
209+
issue or PR:
206210
- GitPython version (e.g. `import git; git.__version__`)
207211
- Python version (e.g. `python --version`)
208212
- The encountered stack-trace, if applicable

‎git/config.py‎

Lines changed: 40 additions & 7 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,23 +632,30 @@ 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
635+
eof_continuation = False
610636
while True:
611637
if not is_line_continuation(optval):
612638
break
613639
continuation = fp.readline()
614640
if not continuation:
615-
# Backslash at end of file: git drops it.
616-
optval = optval[:-1]
641+
# Keep the backslash until parsing so preceding
642+
# whitespace is significant even though git drops it.
643+
eof_continuation = True
617644
break
618645
lineno = lineno + 1
619646
joined = continuation.decode(defenc)
620647
while joined.endswith("\n") or joined.endswith("\r"):
621648
joined = joined[:-1]
622649
optval = optval[:-1] + joined
623-
continued = True
624-
if continued:
650+
# Git decodes the standard escape sequences in unquoted
651+
# values, but only a value in which every backslash is part
652+
# of a known escape is decoded, so that a Windows path keeps
653+
# its backslashes instead of losing them one escape at a time.
654+
optval, _ = strip_inline_comment(optval)
655+
if escapes_are_known(optval):
625656
optval = parse_value(optval)
657+
elif eof_continuation:
658+
optval = optval[:-1]
626659
elif quote_open:
627660
# Opens quoting and does not close: appears to start multi-line quoting.
628661
is_multi_line = True
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: 52 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
]
@@ -378,6 +378,33 @@ def test_backslash_line_continuation(self):
378378
key = "co" if section == "alias" else "k"
379379
self.assertEqual(config.get_value(section, key), expected)
380380

381+
@with_rw_directory
382+
def test_continued_values_preserve_comments_and_eof_whitespace(self, rw_dir):
383+
cases = [
384+
(b"one\\\n two # C:\\Users\n", "one two"),
385+
(b"one\\\n two ; C:\\Users\n", "one two"),
386+
(b"one\\\n two\\tthree # C:\\Users\n", "one two\tthree"),
387+
(b'one\\\n "two # three" # C:\\Users\n', "one two # three"),
388+
(b"value \\", "value "),
389+
(b"value \\\n", "value "),
390+
(b"value \t\\\n", "value \t"),
391+
(b"one\\\n two \\\n", "one two "),
392+
(b"C:\\Users \\\n", "C:\\Users "),
393+
]
394+
config_path = osp.join(rw_dir, "config")
395+
for value, expected in cases:
396+
with self.subTest(value=value):
397+
with open(config_path, "wb") as config_file:
398+
config_file.write(b"[a]\n\tk = " + value)
399+
400+
with GitConfigParser(config_path, read_only=False) as config:
401+
self.assertEqual(config.get_value("a", "k"), expected)
402+
config.set_value("other", "value", "updated")
403+
404+
with GitConfigParser(config_path) as config:
405+
self.assertEqual(config.get_value("a", "k"), expected)
406+
self.assertEqual(config.get_value("other", "value"), "updated")
407+
381408
def test_get_value_reads_git_boolean_spellings(self):
382409
# git accepts yes/no and on/off as well as true/false, and getboolean on
383410
# this class already did. get_value returned them as strings, so "no" and
@@ -1209,6 +1236,30 @@ def test_config_with_quotes_containing_escapes(self):
12091236
# Cases without quotes should still parse correctly even after those, too.
12101237
self.assertEqual(cr.get("custom", "unquoted"), "good evening")
12111238

1239+
def test_config_with_unquoted_escapes(self):
1240+
"""Decode git's escape sequences in unquoted values, but only when every
1241+
backslash in the value is part of a known escape."""
1242+
cr = GitConfigParser(fixture_path("git_config_unquoted_escapes"), read_only=True)
1243+
1244+
self.assertEqual(cr.get("custom", "hasnewline"), "first\nsecond")
1245+
self.assertEqual(cr.get("custom", "hastab"), "first\tsecond")
1246+
self.assertEqual(cr.get("custom", "hasbackslash"), R"foo\bar")
1247+
self.assertEqual(cr.get("custom", "hasquote"), 'ab"cd')
1248+
self.assertEqual(cr.get("custom", "hastrailingbackslash"), "word\\")
1249+
1250+
# Git rejects an unknown escape rather than decoding it. GitPython keeps
1251+
# such a value literal instead of rejecting it, which is what keeps a
1252+
# Windows path usable, so no backslash in one may be decoded: doing so
1253+
# would turn the \t of \Temp\test into a tab while leaving \U alone.
1254+
self.assertEqual(cr.get("custom", "hasunknownescape"), R"foo\qbar")
1255+
self.assertEqual(cr.get("custom", "haswindowspath"), R"C:\Users\RUNNER~1\AppData\Local\Temp\test_foo")
1256+
1257+
# Values without escapes keep parsing as before.
1258+
self.assertEqual(cr.get("custom", "ordinary"), "hello world")
1259+
self.assertEqual(cr.get("custom", "withcolon"), "host:port")
1260+
self.assertEqual(cr.get("custom", "withequals"), "key=value")
1261+
self.assertEqual(cr.get("custom", "withhash"), "value")
1262+
12121263
def test_get_values_works_without_requiring_any_other_calls_first(self):
12131264
file_obj = self._to_memcache(fixture_path("git_config_multiple"))
12141265
cr = GitConfigParser(file_obj, read_only=True)

0 commit comments

Comments
 (0)