diff --git a/git/config.py b/git/config.py index e7f64f7b5..aef881d2e 100644 --- a/git/config.py +++ b/git/config.py @@ -467,6 +467,45 @@ def string_decode(v: str) -> str: # END string_decode + def is_line_continuation(value: str) -> bool: + quoted = escaped = False + for char in value: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == '"': + quoted = not quoted + elif char in "#;" and not quoted: + return False + return escaped + + 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)) + escaped = False + continue + if char.isspace() and not quoted: + if parsed: + whitespace.append(char) + continue + if char in "#;" and not quoted: + break + parsed.extend(whitespace) + whitespace.clear() + if char == "\\": + escaped = True + elif char == '"': + quoted = not quoted + else: + parsed.append(char) + return "".join(parsed) + while True: # We assume to read binary! line = fp.readline().decode(defenc) @@ -513,7 +552,29 @@ def string_decode(v: str) -> str: if len(optval) < 2 or optval[0] != '"': # Does not open quoting. - pass + # A value ending in an odd number of backslashes + # continues on the next line, exactly as git does: the + # final backslash and the newline are removed and the + # 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 + continuation = fp.readline() + if not continuation: + # Backslash at end of file: git drops it. + optval = optval[:-1] + break + lineno = lineno + 1 + joined = continuation.decode(defenc) + while joined.endswith("\n") or joined.endswith("\r"): + joined = joined[:-1] + optval = optval[:-1] + joined + continued = True + if continued: + optval = parse_value(optval) elif optval[-1] != '"': # Opens quoting and does not close: appears to start multi-line quoting. is_multi_line = True diff --git a/test/test_config.py b/test/test_config.py index 28bb12043..3107d8074 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -143,6 +143,44 @@ def test_multi_line_config(self): ) self.assertEqual(len(config.sections()), 23) + def test_backslash_line_continuation(self): + """An unquoted value ending in a backslash continues on the next line, + exactly as git config parses it: the final backslash and the newline + are removed before the complete logical value is parsed.""" + cases = [ + (b"[a]\n\tk = line1\\\n line2\n", "line1 line2"), + (b"[a]\n\tk = one\\\n two\\\n three\n", "one two three"), + (b"[a]\n\tk = one\\\n two \n", "one two"), + (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 = end\\\n", "end"), + (b"[alias]\n\tco = checkout \\\n\t\t-v\n", "checkout \t\t-v"), + ] + for content, expected in cases: + config_file = io.BytesIO(content) + config_file.name = "backslash_continuation.config" + config = GitConfigParser(config_file) + config.read() + section = "alias" if b"[alias]" in content else "a" + key = "co" if section == "alias" else "k" + self.assertEqual(config.get_value(section, key), expected) + + @with_rw_directory + def test_comment_backslash_does_not_continue_value(self, rw_dir): + config_path = osp.join(rw_dir, "config") + with open(config_path, "wb") as config_file: + config_file.write(b"[a]\n\tk = one\\\n two ; ignored \\\n\tx = two\n") + + with GitConfigParser(config_path, read_only=False) as config: + self.assertEqual(config.get_value("a", "k"), "one two") + self.assertEqual(config.get_value("a", "x"), "two") + config.set_value("a", "added", "three") + + with GitConfigParser(config_path) as config: + self.assertEqual(config.get_value("a", "x"), "two") + def test_config_value_with_trailing_new_line(self): config_content = b'[section-header]\nkey:"value\n"' config_file = io.BytesIO(config_content)