| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent 9d3aff0 commit 983e182
3 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -69,6 +69,13 @@ | |||
| 69 | 69 | VALID_CONFIG_OPTION_NAME_RE = re.compile(r"^[A-Za-z0-9_.-]+$") | |
| 70 | 70 | """Pattern for option names that can be written without changing config syntax.""" | |
| 71 | 71 | ||
| 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 | + | ||
| 72 | 79 | ||
| 73 | 80 | class MetaParserBuilder(abc.ABCMeta): # noqa: B024 | |
| 74 | 81 | """Utility class wrapping base-class methods into decorators that assure read-only | |
@@ -530,10 +537,9 @@ def parse_value(value: str) -> str: | |||
| 530 | 537 | parsed: List[str] = [] | |
| 531 | 538 | whitespace: List[str] = [] | |
| 532 | 539 | quoted = escaped = False | |
| 533 | - escapes = {"b": "\b", "n": "\n", "t": "\t", '"': '"', "\\": "\\"} | ||
| 534 | 540 | for char in value: | |
| 535 | 541 | if escaped: | |
| 536 | - parsed.append(escapes.get(char, "\\" + char)) | ||
| 542 | + parsed.append(UNQUOTED_ESCAPES.get(char, "\\" + char)) | ||
| 537 | 543 | escaped = False | |
| 538 | 544 | continue | |
| 539 | 545 | if char.isspace() and not quoted: | |
@@ -552,6 +558,26 @@ def parse_value(value: str) -> str: | |||
| 552 | 558 | parsed.append(char) | |
| 553 | 559 | return "".join(parsed) | |
| 554 | 560 | ||
| 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 | + | ||
| 555 | 581 | while True: | |
| 556 | 582 | # We assume to read binary! | |
| 557 | 583 | raw_line = fp.readline() | |
@@ -606,7 +632,6 @@ def parse_value(value: str) -> str: | |||
| 606 | 632 | # next line is appended before the complete value is | |
| 607 | 633 | # parsed. An even number means the last backslash is | |
| 608 | 634 | # escaped and the value ends there. | |
| 609 | - continued = False | ||
| 610 | 635 | while True: | |
| 611 | 636 | if not is_line_continuation(optval): | |
| 612 | 637 | break | |
@@ -620,8 +645,11 @@ def parse_value(value: str) -> str: | |||
| 620 | 645 | while joined.endswith("\n") or joined.endswith("\r"): | |
| 621 | 646 | joined = joined[:-1] | |
| 622 | 647 | 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): | ||
| 625 | 653 | optval = parse_value(optval) | |
| 626 | 654 | elif quote_open: | |
| 627 | 655 | # Opens quoting and does not close: appears to start multi-line quoting. | |
| Original file line number | Diff line number | Diff 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 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -365,7 +365,7 @@ def test_backslash_line_continuation(self): | |||
| 365 | 365 | (b"[a]\n\tk = one\\\n two ; ignored\n", "one two"), | |
| 366 | 366 | (b'[a]\n\tk = one\\\n "two"\n', "one two"), | |
| 367 | 367 | (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\\"), | ||
| 369 | 369 | (b"[a]\n\tk = end\\\n", "end"), | |
| 370 | 370 | (b"[alias]\n\tco = checkout \\\n\t\t-v\n", "checkout \t\t-v"), | |
| 371 | 371 | ] | |
@@ -1209,6 +1209,30 @@ def test_config_with_quotes_containing_escapes(self): | |||
| 1209 | 1209 | # Cases without quotes should still parse correctly even after those, too. | |
| 1210 | 1210 | self.assertEqual(cr.get("custom", "unquoted"), "good evening") | |
| 1211 | 1211 | ||
| 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 | + | ||
| 1212 | 1236 | def test_get_values_works_without_requiring_any_other_calls_first(self): | |
| 1213 | 1237 | file_obj = self._to_memcache(fixture_path("git_config_multiple")) | |
| 1214 | 1238 | cr = GitConfigParser(file_obj, read_only=True) | |
| Back | FazBrowse Home | New Git URL |
0 commit comments