Skip to content

Commit 890cedc

Browse files
claudespoorcc
authored andcommitted
fix: tolerate non-UTF-8 svn output instead of crashing (#1383)
SVN servers can emit output in the system code page (e.g. CP1252 on Windows) rather than UTF-8, for example when a file path contains accented characters. Every decode of subprocess output in dfetch.vcs.svn now goes through util.cmdline.decode_subprocess_output, which tries UTF-8, falls back to CP1252, and finally replaces any byte it still can't decode, so commands like `dfetch update` no longer crash with UnicodeDecodeError on such output.
1 parent 203960d commit 890cedc

5 files changed

Lines changed: 72 additions & 31 deletions

File tree

CHANGELOG.rst

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
Release 0.14.4 (unreleased)
2+
====================================
3+
4+
* Fix SVN commands raising ``UnicodeDecodeError`` on non-UTF-8 output (#1383)
5+
16
Release 0.14.3 (released 2026-06-25)
27
====================================
38

dfetch/util/cmdline.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,26 @@ def message(self) -> str:
3636
return self._message
3737

3838

39+
def decode_subprocess_output(data: bytes) -> str:
40+
"""Decode bytes from a subprocess, tolerating non-UTF-8 output.
41+
42+
Command line tools such as ``svn`` can emit output in the system's
43+
native code page (e.g. CP1252 on Windows) rather than UTF-8, for
44+
example when a file path contains accented characters. UTF-8 is tried
45+
first since it is the common case, then CP1252, a common source of
46+
non-UTF-8 output. As a last resort, undecodable bytes are replaced so
47+
that unexpected output never crashes the command that produced it.
48+
"""
49+
try:
50+
return data.decode()
51+
except UnicodeDecodeError:
52+
pass
53+
try:
54+
return data.decode(encoding="cp1252")
55+
except UnicodeDecodeError:
56+
return data.decode(errors="replace")
57+
58+
3959
def run_on_cmdline(
4060
logger: logging.Logger,
4161
cmd: list[str],
@@ -52,8 +72,8 @@ def run_on_cmdline(
5272
except subprocess.CalledProcessError as exc:
5373
raise SubprocessCommandError(
5474
exc.cmd,
55-
exc.output.decode(errors="replace").strip(),
56-
exc.stderr.decode(errors="replace").strip(),
75+
decode_subprocess_output(exc.output).strip(),
76+
decode_subprocess_output(exc.stderr).strip(),
5777
exc.returncode,
5878
) from exc
5979
except FileNotFoundError as exc:
@@ -66,8 +86,8 @@ def run_on_cmdline(
6686
if proc.returncode:
6787
raise SubprocessCommandError(
6888
cmd,
69-
stdout.decode(errors="replace"),
70-
stderr.decode(errors="replace").strip(),
89+
decode_subprocess_output(stdout),
90+
decode_subprocess_output(stderr).strip(),
7191
proc.returncode,
7292
)
7393

@@ -83,9 +103,5 @@ def _log_output(proc: subprocess.CompletedProcess, logger: logging.Logger) -> No
83103

84104
def _log_output_stream(name: str, stream: Any, logger: logging.Logger) -> None:
85105
logger.debug(f"{name}:")
86-
try:
87-
for line in stream.decode().split("\n\n"):
88-
logger.debug(line)
89-
except UnicodeDecodeError:
90-
for line in stream.decode(encoding="cp1252").split("\n\n"):
91-
logger.debug(line)
106+
for line in decode_subprocess_output(stream).split("\n\n"):
107+
logger.debug(line)

dfetch/vcs/svn.py

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,11 @@
1414
from urllib.parse import urlparse
1515

1616
from dfetch.log import get_logger
17-
from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline
17+
from dfetch.util.cmdline import (
18+
SubprocessCommandError,
19+
decode_subprocess_output,
20+
run_on_cmdline,
21+
)
1822
from dfetch.util.util import in_directory
1923
from dfetch.vcs.patch import Patch, PatchType
2024

@@ -85,7 +89,7 @@ def _run_svn_raw(args: list[str], *, url: str = "") -> bytes:
8589

8690
def _run_svn(args: list[str], *, url: str = "") -> str:
8791
"""Run an svn subcommand and return decoded stdout (see _run_svn_raw)."""
88-
return _run_svn_raw(args, url=url).decode()
92+
return decode_subprocess_output(_run_svn_raw(args, url=url))
8993

9094

9195
def get_svn_version() -> tuple[str, str]:
@@ -303,7 +307,7 @@ def _inherited_auto_props(self, directory: str) -> str:
303307
raise
304308
except (SubprocessCommandError, RuntimeError):
305309
continue
306-
return result.decode()
310+
return decode_subprocess_output(result)
307311
return ""
308312

309313
def externals(self) -> list[External]:
@@ -461,7 +465,9 @@ def get_last_changed_revision(target: str | Path) -> str:
461465
target_str = str(target).strip()
462466
if os.path.isdir(target_str):
463467
last_digits = re.compile(r"(?P<digits>\d+)(?!.*\d)")
464-
version = run_on_cmdline(logger, ["svnversion", target_str]).stdout.decode()
468+
version = decode_subprocess_output(
469+
run_on_cmdline(logger, ["svnversion", target_str]).stdout
470+
)
465471

466472
parsed_version = last_digits.search(version)
467473
if parsed_version:

tests/test_cmdline.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@
1010

1111
import pytest
1212

13-
from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline
13+
from dfetch.util.cmdline import (
14+
SubprocessCommandError,
15+
decode_subprocess_output,
16+
run_on_cmdline,
17+
)
1418

1519
LS_CMD = "ls ."
1620
LS_OK_RESULT = CompletedProcess(
@@ -44,3 +48,15 @@ def test_run_on_cmdline(name, cmd, cmd_result, expectation):
4448
else:
4549
with pytest.raises(expectation):
4650
run_on_cmdline(logger_mock, cmd)
51+
52+
53+
@pytest.mark.parametrize(
54+
"name, data, expected",
55+
[
56+
("utf-8", "café".encode(), "café"),
57+
("cp1252 fallback", "café".encode("cp1252"), "café"),
58+
("undefined in both codecs is replaced", b"\x81", "�"),
59+
],
60+
)
61+
def test_decode_subprocess_output(name, data, expected):
62+
assert decode_subprocess_output(data) == expected, name

tests/test_svn_vcs.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -115,35 +115,33 @@ def test_eol_style_for_without_svn_returns_none(tmp_path):
115115
assert SvnRepo(tmp_path).eol_style_for("ext/mylib/_") is None
116116

117117

118-
def test_export_raises_unicodedecodeerror_on_non_utf8_output():
119-
"""Reproduces #1383: export() must not crash on non-UTF-8 svn stdout.
118+
def test_export_tolerates_non_utf8_output():
119+
"""Regression test for #1383: export() must not crash on non-UTF-8 svn stdout.
120120
121121
Real-world SVN servers (e.g. accessed over HTTP on Windows) can emit
122122
output in the system code page (such as CP1252) rather than UTF-8, for
123123
example when printing exported file paths containing accented
124-
characters. ``_run_svn`` currently hardcodes ``.decode()`` (UTF-8),
125-
so a byte sequence that is invalid UTF-8 but valid CP1252 (like 0xe9,
126-
"e" with an acute accent) crashes the whole update instead of being
127-
handled gracefully.
124+
characters. ``_run_svn`` used to hardcode ``.decode()`` (UTF-8), so a
125+
byte sequence that is invalid UTF-8 but valid CP1252 (like 0xe9, "e"
126+
with an acute accent) crashed the whole update instead of the export
127+
completing.
128128
"""
129129
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
130130
mock_run.return_value.stdout = b"A caf\xe9.txt\n"
131-
with pytest.raises(UnicodeDecodeError):
132-
SvnRepo.export("svn://example.com/repo", dst="/tmp/out")
131+
SvnRepo.export("svn://example.com/repo", dst="/tmp/out")
133132

134133

135-
def test_run_svn_raises_unicodedecodeerror_on_non_utf8_output():
136-
"""Reproduces #1383 at the lower level: any _run_svn caller can crash.
134+
def test_run_svn_tolerates_non_utf8_output():
135+
"""Regression test for #1383 at the lower level: any _run_svn caller must not crash.
137136
138137
``_run_svn`` is used by several ``SvnRepo`` methods (info, externals,
139-
files_in_path, ...). None of them can currently tolerate non-UTF-8
140-
bytes in svn's stdout, since the decode happens unconditionally before
141-
the caller ever sees the output.
138+
files_in_path, ...). Non-UTF-8 bytes in svn's stdout must be decoded
139+
with a fallback (CP1252) instead of raising, and CP1252-decodable
140+
bytes must round-trip to the original text.
142141
"""
143142
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
144-
mock_run.return_value.stdout = b"Path: caf\xe9\n"
145-
with pytest.raises(UnicodeDecodeError):
146-
SvnRepo.files_in_path("svn://example.com/repo/caf\xe9")
143+
mock_run.return_value.stdout = "Path: café\n".encode("cp1252")
144+
assert SvnRepo.files_in_path("svn://example.com/repo/café") == ["Path: café"]
147145

148146

149147
def test_export_rejects_non_digit_revision():

0 commit comments

Comments
 (0)