diff --git a/git/config.py b/git/config.py index 732d3658b..8ed4afbc2 100644 --- a/git/config.py +++ b/git/config.py @@ -578,17 +578,42 @@ def escapes_are_known(value: str) -> bool: index = value.find("\\", index + 2) return True + def split_section_header(line: str) -> Tuple[str, str]: + """Split a section header line into the header and the text after it. + + Like git, the header ends at the first ``]`` outside a quoted subsection + name. A header whose quotes are unbalanced keeps the previous reading up + to the last ``]``. + """ + quoted = escaped = False + for index, char in enumerate(line): + if escaped: + escaped = False + elif char == "\\" and quoted: + escaped = True + elif char == '"': + quoted = not quoted + elif char == "]" and not quoted and index > 1: + return line[1:index], line[index + 1 :] + return line[1 : line.rindex("]")], "" + + # Text after a section header on the same line, as in `[core] bare = true`. + # Git reads it as if it started a line of its own. + rest_of_line = "" while True: - # We assume to read binary! - raw_line = fp.readline() - if not raw_line: - break - if lineno == 0 and raw_line.startswith(b"\xef\xbb\xbf"): - # A UTF-8 BOM is not part of the content. git skips it, so a - # config file written by a Windows editor still parses. - raw_line = raw_line[3:] - line = raw_line.decode(defenc) - lineno = lineno + 1 + if rest_of_line: + line, rest_of_line = rest_of_line, "" + else: + # We assume to read binary! + raw_line = fp.readline() + if not raw_line: + break + if lineno == 0 and raw_line.startswith(b"\xef\xbb\xbf"): + # A UTF-8 BOM is not part of the content. git skips it, so a + # config file written by a Windows editor still parses. + raw_line = raw_line[3:] + line = raw_line.decode(defenc) + lineno = lineno + 1 # Comment or blank line? if line.strip() == "" or self.re_comment.match(line): continue @@ -599,7 +624,8 @@ def escapes_are_known(value: str) -> bool: # Is it a section header? mo = self.SECTCRE.match(line.strip()) if not is_multi_line and mo: - sectname: str = mo.group("header").strip() + header, rest_of_line = split_section_header(line.strip()) + sectname: str = header.strip() if sectname in self._sections: cursect = self._sections[sectname] elif sectname == cp.DEFAULTSECT: diff --git a/test/test_config.py b/test/test_config.py index 49b8535c7..59d8f8a67 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -462,6 +462,38 @@ def test_utf8_bom_is_skipped_like_git(self): config.read() self.assertIs(config.get_value("core", "bare"), True) + def test_option_after_section_header_is_read_like_git(self): + """git ends a section header at its first unquoted ``]`` and reads the rest + of the line as an option. Expectations are what + `git config -f --list` prints on git 2.54.0.""" + content = ( + b'[submodule "lib"]\n' + b"\tpath = lib\n" + b"\turl = https://good.example/lib\n" + b'[submodule "lib"] url = https://evil.example/lib\n' + b'[a "x]y"] k = v # comment\n' + b"[b]flag\n" + b"[c] k = v ]\n" + b'[d] k = "multi\\\n' + b' line"\n' + b"[e] ; comment\n" + b"[f] [g] k = v\n" + ) + config_file = io.BytesIO(content) + config_file.name = "header.config" + config = GitConfigParser(config_file) + config.read() + + self.assertEqual(config.get_value('submodule "lib"', "url"), "https://evil.example/lib") + self.assertEqual(config.get_value('a "x]y"', "k"), "v") + self.assertIs(config.getboolean("b", "flag"), True) + self.assertEqual(config.get_value("c", "k"), "v ]") + self.assertEqual(config.get_value("d", "k"), "multi line") + self.assertEqual(config.items("e"), []) + self.assertEqual(config.items("f"), []) + self.assertEqual(config.get_value("g", "k"), "v") + self.assertEqual(config.sections(), ['submodule "lib"', 'a "x]y"', "b", "c", "d", "e", "f", "g"]) + def test_config_value_with_trailing_new_line(self): config_content = b'[section-header]\nkey:"value\n"' config_file = io.BytesIO(config_content)