Skip to content

Commit 318a008

Browse files
codexByron
authored andcommitted
fix(config): preserve bytes in subsection names and values
Git accepts configuration subsection names and values containing bytes that are not valid in the filesystem encoding. Strict decoding made configuration enumeration, remote names and URLs fail with `UnicodeDecodeError`; byte-valued writes also decoded strictly before reaching Git. Use the shared lossless decoder for Git's NUL-framed records and incoming byte values. Keep existing key validation, mutation framing and Git-managed locking. File and BytesIO regressions compare against `git config --null`, exercise remote names/URLs and verify exact bytes after writes. Four regressions fail before the fix. All 66 configuration tests pass on each backend with Git 2.54.0 and GixPython 0.3.0. Ruff, mypy and whitespace checks pass. Windows byte-valued argument writes remain excluded because Windows command-line arguments cannot preserve arbitrary original bytes.
1 parent 9e9f159 commit 318a008

4 files changed

Lines changed: 46 additions & 4 deletions

File tree

‎doc/source/changes.rst‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ API changes
125125
including updates when ``logmsg`` is omitted.
126126
* ``GitConfigParser`` uses Git's configuration syntax and canonical key spelling,
127127
and no longer subclasses ``configparser.RawConfigParser``.
128+
Subsection names and values preserve undecodable bytes with ``surrogateescape``.
128129
Common getters, typed values, duplicate values, file/stream inputs and mutation
129130
methods remain, including ``GitConfigParser[section] = values`` for replacing
130131
nonempty sections. Invalid Git syntax previously accepted by Python is rejected.

‎git/config.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
from typing import Any, Dict, Generic, Iterator, List, OrderedDict, Sequence, Tuple, TypeVar, Union, TYPE_CHECKING
1818
from typing import Mapping
1919

20-
from git.compat import defenc, force_text
20+
from git.compat import safe_decode
2121
from git.exc import GitCommandError
2222
from git.types import _T, ConfigLevels_Tup, Lit_config_levels, PathLike, assert_never
2323

@@ -368,11 +368,11 @@ def read(self) -> None:
368368
if not record:
369369
continue
370370
key, separator, value = record.partition(b"\n")
371-
prefix, option = key.decode(defenc).rsplit(".", 1)
371+
prefix, option = safe_decode(key).rsplit(".", 1)
372372
section = self._public_section(prefix)
373373
if section not in sections:
374374
sections[section] = _OMD()
375-
sections[section].add(option, value.decode(defenc) if separator else None)
375+
sections[section].add(option, safe_decode(value) if separator else None)
376376
self._sections = sections
377377
self._is_initialized = True
378378

@@ -476,7 +476,7 @@ def _mutate(self, operation: str, *args: str) -> None:
476476
def _value(value: Any) -> str:
477477
if value is None:
478478
raise ValueError("Writing valueless Git configuration options is unsupported")
479-
value = str(value) if isinstance(value, (int, float, bool)) else force_text(value)
479+
value = str(value) if isinstance(value, (int, float, bool)) else safe_decode(value)
480480
if any(c in value for c in "\0\r"):
481481
raise ValueError("Git configuration values must not contain CR or NUL")
482482
return value

‎test/test_config.py‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,42 @@ def test_config_values_and_mutations(config_directory, stream):
6969
assert (source.getvalue() if stream else path.read_bytes()).startswith(b"[CoRe]\nFlag\n")
7070

7171

72+
@pytest.mark.parametrize("stream", [False, True])
73+
def test_config_preserves_non_utf8_subsections_and_values(stream):
74+
path = read_only_fixture("config-bytes.sh")
75+
source = BytesIO((path / "config").read_bytes()) if stream else path / "config"
76+
assert (
77+
Git()
78+
.config("list", "--file", str(path / "config"), "--null", stdout_as_string=False)
79+
.endswith(b"remote.origin-\xff.url\ntarget-\xfe\0")
80+
)
81+
with GitConfigParser(source) as config:
82+
assert 'remote "origin-\udcff"' in config.sections()
83+
assert config.get('remote "origin-\udcff"', "url") == "target-\udcfe"
84+
with Repo(path / "repo") as repo:
85+
remote = repo.remotes[0]
86+
assert remote.name == "origin-\udcff"
87+
assert remote.url == "target-\udcfe"
88+
89+
90+
@pytest.mark.skipif(os.name == "nt", reason="Windows command arguments cannot carry arbitrary bytes")
91+
@pytest.mark.parametrize("stream", [False, True])
92+
def test_config_mutations_preserve_non_utf8_values(stream):
93+
with writable_fixture("config-bytes.sh") as fixture:
94+
path = fixture.path() / "config"
95+
source = BytesIO(path.read_bytes()) if stream else path
96+
with GitConfigParser(source, read_only=False) as config:
97+
section = 'remote "origin-\udcff"'
98+
assert config.get(section, "url") == "target-\udcfe"
99+
config.set_value(section, "url", "updated-\udcfd")
100+
config.add_value(section, "url", b"bytes-\xfc")
101+
assert config.get_values(section, "url") == ["updated-\udcfd", "bytes-\udcfc"]
102+
content = source.getvalue() if stream else path.read_bytes()
103+
assert b'"origin-\xff"' in content
104+
assert b"updated-\xfd" in content
105+
assert b"bytes-\xfc" in content
106+
107+
72108
def test_config_includes_and_repository_conditions():
73109
path = read_only_fixture("config.sh", "includes")
74110
with Repo(path / "repo") as repo:

‎tests/fixtures/config-bytes.sh‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
#!/usr/bin/env bash
2+
set -eu
3+
git init -q repo
4+
printf '[remote "origin-\377"]\n\turl = target-\376\n' >> repo/.git/config
5+
cp repo/.git/config config

0 commit comments

Comments
 (0)