Skip to content

Commit 5d550c7

Browse files
committed
Prevent hanging in svn+ssh
1 parent 9006313 commit 5d550c7

3 files changed

Lines changed: 185 additions & 27 deletions

File tree

CHANGELOG.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Release 0.14.0 (unreleased)
2121
* Prevent SSH command injection (#1152)
2222
* Allow manifests with no ``projects`` key so ``dfetch add`` can bootstrap empty manifest (#1197)
2323
* Fix ``ValueError`` when generating a PackageURL (e.g. for an SBOM) from an empty or path-only remote URL
24+
* Run ``svn+ssh://`` connections in non-interactive mode to prevent hanging (#1230)
2425

2526
Release 0.13.0 (released 2026-03-30)
2627
====================================

dfetch/vcs/svn.py

Lines changed: 99 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
"""Svn repository."""
22

33
import contextlib
4+
import functools
45
import os
56
import pathlib
67
import re
78
from collections.abc import Callable, Generator, Sequence
89
from pathlib import Path
910
from typing import NamedTuple
11+
from urllib.parse import urlparse
1012

1113
from dfetch.log import get_logger
1214
from dfetch.util.cmdline import SubprocessCommandError, run_on_cmdline
@@ -15,6 +17,38 @@
1517

1618
logger = get_logger(__name__)
1719

20+
_SSH_HOST_KEY_MSGS = ("host key verification failed", "authenticity of host")
21+
22+
23+
# As a cli tool, we can safely assume this remains stable during the runtime, caching for speed is better
24+
@functools.lru_cache
25+
def _extend_env_for_non_interactive_mode() -> dict[str, str]:
26+
"""Extend the environment vars for svn running in non-interactive mode."""
27+
env = os.environ.copy()
28+
ssh_cmd = env.get("SVN_SSH", "ssh")
29+
if "BatchMode=" not in ssh_cmd:
30+
ssh_cmd += " -o BatchMode=yes"
31+
else:
32+
logger.debug('BatchMode already configured in SVN_SSH: "%s"', ssh_cmd)
33+
env["SVN_SSH"] = ssh_cmd
34+
return env
35+
36+
37+
def _raise_if_ssh_host_key_error(url: str, exc: SubprocessCommandError) -> None:
38+
"""Raise a helpful RuntimeError if *exc* looks like an SSH host-key failure."""
39+
stderr_lower = exc.stderr.lower()
40+
if any(msg in stderr_lower for msg in _SSH_HOST_KEY_MSGS):
41+
parsed = urlparse(url)
42+
host = parsed.hostname or url
43+
target = f"{parsed.username}@{host}" if parsed.username else host
44+
raise RuntimeError(
45+
f"SSH host key verification failed while connecting to '{url}'.\n"
46+
"Add the host to your known hosts file, for example by running:\n"
47+
f" ssh-keyscan {host} >> ~/.ssh/known_hosts\n"
48+
"Or test the SSH connection manually:\n"
49+
f" ssh -T {target}"
50+
) from exc
51+
1852

1953
def get_svn_version() -> tuple[str, str]:
2054
"""Get the name and version of svn."""
@@ -49,9 +83,14 @@ def __init__(self, remote: str) -> None:
4983
def is_svn(self) -> bool:
5084
"""Check if is SVN."""
5185
try:
52-
run_on_cmdline(logger, ["svn", "info", self._remote, "--non-interactive"])
86+
run_on_cmdline(
87+
logger,
88+
["svn", "info", self._remote, "--non-interactive"],
89+
env=_extend_env_for_non_interactive_mode(),
90+
)
5391
return True
5492
except SubprocessCommandError as exc:
93+
_raise_if_ssh_host_key_error(self._remote, exc)
5594
if exc.stderr.startswith("svn: E170013"):
5695
raise RuntimeError(
5796
f">>>{exc.cmd}<<< failed!\n"
@@ -67,20 +106,30 @@ def list_of_branches(self) -> list[str]:
67106
result = run_on_cmdline(
68107
logger,
69108
["svn", "ls", "--non-interactive", f"{self._remote}/branches"],
109+
env=_extend_env_for_non_interactive_mode(),
70110
)
71111
return [
72112
line.strip("/\r")
73113
for line in result.stdout.decode().splitlines()
74114
if line.strip("/\r")
75115
]
76-
except (SubprocessCommandError, RuntimeError):
116+
except SubprocessCommandError as exc:
117+
_raise_if_ssh_host_key_error(self._remote, exc)
118+
return []
119+
except RuntimeError:
77120
return []
78121

79122
def list_of_tags(self) -> list[str]:
80123
"""Get list of all available tags."""
81-
result = run_on_cmdline(
82-
logger, ["svn", "ls", "--non-interactive", f"{self._remote}/tags"]
83-
)
124+
try:
125+
result = run_on_cmdline(
126+
logger,
127+
["svn", "ls", "--non-interactive", f"{self._remote}/tags"],
128+
env=_extend_env_for_non_interactive_mode(),
129+
)
130+
except SubprocessCommandError as exc:
131+
_raise_if_ssh_host_key_error(self._remote, exc)
132+
raise
84133
return [
85134
str(tag).strip("/\r") for tag in result.stdout.decode().split("\n") if tag
86135
]
@@ -116,7 +165,9 @@ def ls_tree(self, url_path: str) -> list[tuple[str, bool]]:
116165
"""List immediate children of *url_path* as ``(name, is_dir)`` pairs."""
117166
try:
118167
result = run_on_cmdline(
119-
logger, ["svn", "ls", "--non-interactive", url_path]
168+
logger,
169+
["svn", "ls", "--non-interactive", url_path],
170+
env=_extend_env_for_non_interactive_mode(),
120171
)
121172
entries: list[tuple[str, bool]] = []
122173
for line in result.stdout.decode().splitlines():
@@ -126,7 +177,10 @@ def ls_tree(self, url_path: str) -> list[tuple[str, bool]]:
126177
is_dir = line.endswith("/")
127178
entries.append((line.rstrip("/"), is_dir))
128179
return entries
129-
except (SubprocessCommandError, RuntimeError):
180+
except SubprocessCommandError as exc:
181+
_raise_if_ssh_host_key_error(url_path, exc)
182+
return []
183+
except RuntimeError:
130184
return []
131185

132186

@@ -176,7 +230,13 @@ def externals_from_url(url: str, revision: str = "") -> list[External]:
176230
if revision:
177231
cmd += ["--revision", revision]
178232
cmd += [url]
179-
result = run_on_cmdline(logger, cmd)
233+
try:
234+
result = run_on_cmdline(
235+
logger, cmd, env=_extend_env_for_non_interactive_mode()
236+
)
237+
except SubprocessCommandError as exc:
238+
_raise_if_ssh_host_key_error(url, exc)
239+
raise
180240
repo_root = SvnRepo.get_info_from_target(url)["Repository Root"]
181241
normalized = SvnRepo._normalize_url_prefix(result.stdout.decode(), url)
182242
return SvnRepo._parse_externals(normalized, repo_root)
@@ -292,9 +352,12 @@ def get_info_from_target(target: str = "") -> dict[str, str]:
292352
"""Get the info of the given target."""
293353
try:
294354
result = run_on_cmdline(
295-
logger, ["svn", "info", "--non-interactive", target.strip()]
355+
logger,
356+
["svn", "info", "--non-interactive", target.strip()],
357+
env=_extend_env_for_non_interactive_mode(),
296358
).stdout.decode()
297359
except SubprocessCommandError as exc:
360+
_raise_if_ssh_host_key_error(target, exc)
298361
if exc.stderr.startswith("svn: E170013"):
299362
raise RuntimeError(
300363
f">>>{exc.cmd}<<< failed!\n"
@@ -324,8 +387,8 @@ def get_last_changed_revision(target: str | Path) -> str:
324387
return parsed_version.group("digits")
325388
raise RuntimeError(f"svnversion output was unexpected: {version}")
326389

327-
return str(
328-
run_on_cmdline(
390+
try:
391+
result = run_on_cmdline(
329392
logger,
330393
[
331394
"svn",
@@ -335,10 +398,12 @@ def get_last_changed_revision(target: str | Path) -> str:
335398
"last-changed-revision",
336399
target_str,
337400
],
401+
env=_extend_env_for_non_interactive_mode(),
338402
)
339-
.stdout.decode()
340-
.strip()
341-
)
403+
except SubprocessCommandError as exc:
404+
_raise_if_ssh_host_key_error(target_str, exc)
405+
raise
406+
return str(result.stdout.decode().strip())
342407

343408
@staticmethod
344409
def untracked_files(path: str, ignore: Sequence[str]) -> list[str]:
@@ -377,24 +442,31 @@ def export(url: str, rev: str = "", dst: str = ".") -> None:
377442
"""
378443
if rev and not rev.isdigit():
379444
raise ValueError(f"SVN revision must be digits only, got: {rev!r}")
380-
run_on_cmdline(
381-
logger,
382-
["svn", "export", "--non-interactive", "--force"]
383-
+ (["--revision", rev] if rev else [])
384-
+ [url, dst],
385-
)
445+
try:
446+
run_on_cmdline(
447+
logger,
448+
["svn", "export", "--non-interactive", "--force"]
449+
+ (["--revision", rev] if rev else [])
450+
+ [url, dst],
451+
env=_extend_env_for_non_interactive_mode(),
452+
)
453+
except SubprocessCommandError as exc:
454+
_raise_if_ssh_host_key_error(url, exc)
455+
raise
386456

387457
@staticmethod
388458
def files_in_path(url_path: str) -> list[str]:
389459
"""List all files in path at the given url."""
390-
return [
391-
str(line)
392-
for line in run_on_cmdline(
393-
logger, ["svn", "list", "--non-interactive", url_path]
460+
try:
461+
result = run_on_cmdline(
462+
logger,
463+
["svn", "list", "--non-interactive", url_path],
464+
env=_extend_env_for_non_interactive_mode(),
394465
)
395-
.stdout.decode()
396-
.splitlines()
397-
]
466+
except SubprocessCommandError as exc:
467+
_raise_if_ssh_host_key_error(url_path, exc)
468+
raise
469+
return [str(line) for line in result.stdout.decode().splitlines()]
398470

399471
@staticmethod
400472
def ignored_files(path: str) -> Sequence[str]:

tests/test_svn.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,3 +520,88 @@ def test_externals_from_url_nonstd_layout_branch_is_space():
520520
assert result[0].url == nonstd_url
521521
assert result[0].revision == ""
522522
assert result[0].path == "Database"
523+
524+
525+
@pytest.mark.parametrize(
526+
"method,url,call_args",
527+
[
528+
("is_svn", "svn+ssh://svn.code.sf.net/project", ()),
529+
("list_of_tags", "svn+ssh://svn.code.sf.net/project", ()),
530+
("list_of_branches", "svn+ssh://svn.code.sf.net/project", ()),
531+
(
532+
"ls_tree",
533+
"svn+ssh://svn.code.sf.net/project",
534+
("svn+ssh://svn.code.sf.net/project",),
535+
),
536+
],
537+
)
538+
def test_svn_remote_raises_hint_on_ssh_host_key_failure(method, url, call_args):
539+
stderr = "Host key verification failed."
540+
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
541+
mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1)
542+
543+
with pytest.raises(RuntimeError, match="known hosts"):
544+
getattr(SvnRemote(url), method)(*call_args)
545+
546+
547+
def test_get_info_from_target_raises_hint_on_ssh_host_key_failure():
548+
stderr = "Host key verification failed."
549+
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
550+
mock_run.side_effect = SubprocessCommandError(["svn", "info"], "", stderr, 1)
551+
552+
with pytest.raises(RuntimeError, match="known hosts"):
553+
SvnRepo.get_info_from_target("svn+ssh://svn.code.sf.net/project")
554+
555+
556+
@pytest.mark.parametrize(
557+
"method,url",
558+
[
559+
("externals_from_url", "svn+ssh://svn.code.sf.net/project"),
560+
("get_last_changed_revision", "svn+ssh://svn.code.sf.net/project"),
561+
("export", "svn+ssh://svn.code.sf.net/project"),
562+
("files_in_path", "svn+ssh://svn.code.sf.net/project"),
563+
],
564+
)
565+
def test_svn_repo_raises_hint_on_ssh_host_key_failure(method, url):
566+
stderr = "Host key verification failed."
567+
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
568+
mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1)
569+
570+
with pytest.raises(RuntimeError, match="known hosts"):
571+
getattr(SvnRepo, method)(url)
572+
573+
574+
def test_ssh_hint_includes_hostname():
575+
stderr = "Host key verification failed."
576+
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
577+
mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1)
578+
579+
with pytest.raises(RuntimeError, match="svn.code.sf.net"):
580+
SvnRemote("svn+ssh://svn.code.sf.net/project").is_svn()
581+
582+
583+
def test_ssh_hint_includes_user_when_present_in_url():
584+
stderr = "Host key verification failed."
585+
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
586+
mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1)
587+
588+
with pytest.raises(RuntimeError, match="myuser@svn.code.sf.net"):
589+
SvnRemote("svn+ssh://myuser@svn.code.sf.net/project").is_svn()
590+
591+
592+
def test_svn_ssh_env_has_batch_mode():
593+
from dfetch.vcs.svn import _extend_env_for_non_interactive_mode
594+
595+
_extend_env_for_non_interactive_mode.cache_clear()
596+
env = _extend_env_for_non_interactive_mode()
597+
assert "BatchMode=yes" in env["SVN_SSH"]
598+
599+
600+
def test_svn_ssh_env_preserves_existing_batch_mode(monkeypatch):
601+
from dfetch.vcs.svn import _extend_env_for_non_interactive_mode
602+
603+
monkeypatch.setenv("SVN_SSH", "ssh -o BatchMode=yes -i /my/key")
604+
_extend_env_for_non_interactive_mode.cache_clear()
605+
env = _extend_env_for_non_interactive_mode()
606+
assert env["SVN_SSH"].count("BatchMode=yes") == 1
607+
assert "-i /my/key" in env["SVN_SSH"]

0 commit comments

Comments
 (0)