Skip to content

Commit ce9644c

Browse files
committed
Prevent hanging in svn+ssh
1 parent e43daa7 commit ce9644c

4 files changed

Lines changed: 139 additions & 11 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 (#0)
2425

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

dfetch/vcs/svn.py

Lines changed: 76 additions & 10 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,43 @@
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 _ssh_target_from_url(url: str) -> str:
38+
"""Return the ``[user@]host`` portion of a svn+ssh URL, or the URL itself."""
39+
parsed = urlparse(url)
40+
host = parsed.hostname or url
41+
return f"{parsed.username}@{host}" if parsed.username else host
42+
43+
44+
def _raise_if_ssh_host_key_error(url: str, exc: SubprocessCommandError) -> None:
45+
"""Raise a helpful RuntimeError if *exc* looks like an SSH host-key failure."""
46+
stderr_lower = exc.stderr.lower()
47+
if any(msg in stderr_lower for msg in _SSH_HOST_KEY_MSGS):
48+
target = _ssh_target_from_url(url)
49+
raise RuntimeError(
50+
f"SSH host key verification failed while connecting to '{url}'.\n"
51+
"Add the host to your known hosts file, for example by running:\n"
52+
f" ssh-keyscan {target} >> ~/.ssh/known_hosts\n"
53+
"Or test the SSH connection manually:\n"
54+
f" ssh -T {target}"
55+
) from exc
56+
1857

1958
def get_svn_version() -> tuple[str, str]:
2059
"""Get the name and version of svn."""
@@ -49,9 +88,14 @@ def __init__(self, remote: str) -> None:
4988
def is_svn(self) -> bool:
5089
"""Check if is SVN."""
5190
try:
52-
run_on_cmdline(logger, ["svn", "info", self._remote, "--non-interactive"])
91+
run_on_cmdline(
92+
logger,
93+
["svn", "info", self._remote, "--non-interactive"],
94+
env=_extend_env_for_non_interactive_mode(),
95+
)
5396
return True
5497
except SubprocessCommandError as exc:
98+
_raise_if_ssh_host_key_error(self._remote, exc)
5599
if exc.stderr.startswith("svn: E170013"):
56100
raise RuntimeError(
57101
f">>>{exc.cmd}<<< failed!\n"
@@ -67,20 +111,30 @@ def list_of_branches(self) -> list[str]:
67111
result = run_on_cmdline(
68112
logger,
69113
["svn", "ls", "--non-interactive", f"{self._remote}/branches"],
114+
env=_extend_env_for_non_interactive_mode(),
70115
)
71116
return [
72117
line.strip("/\r")
73118
for line in result.stdout.decode().splitlines()
74119
if line.strip("/\r")
75120
]
76-
except (SubprocessCommandError, RuntimeError):
121+
except SubprocessCommandError as exc:
122+
_raise_if_ssh_host_key_error(self._remote, exc)
123+
return []
124+
except RuntimeError:
77125
return []
78126

79127
def list_of_tags(self) -> list[str]:
80128
"""Get list of all available tags."""
81-
result = run_on_cmdline(
82-
logger, ["svn", "ls", "--non-interactive", f"{self._remote}/tags"]
83-
)
129+
try:
130+
result = run_on_cmdline(
131+
logger,
132+
["svn", "ls", "--non-interactive", f"{self._remote}/tags"],
133+
env=_extend_env_for_non_interactive_mode(),
134+
)
135+
except SubprocessCommandError as exc:
136+
_raise_if_ssh_host_key_error(self._remote, exc)
137+
raise
84138
return [
85139
str(tag).strip("/\r") for tag in result.stdout.decode().split("\n") if tag
86140
]
@@ -116,7 +170,9 @@ def ls_tree(self, url_path: str) -> list[tuple[str, bool]]:
116170
"""List immediate children of *url_path* as ``(name, is_dir)`` pairs."""
117171
try:
118172
result = run_on_cmdline(
119-
logger, ["svn", "ls", "--non-interactive", url_path]
173+
logger,
174+
["svn", "ls", "--non-interactive", url_path],
175+
env=_extend_env_for_non_interactive_mode(),
120176
)
121177
entries: list[tuple[str, bool]] = []
122178
for line in result.stdout.decode().splitlines():
@@ -126,7 +182,10 @@ def ls_tree(self, url_path: str) -> list[tuple[str, bool]]:
126182
is_dir = line.endswith("/")
127183
entries.append((line.rstrip("/"), is_dir))
128184
return entries
129-
except (SubprocessCommandError, RuntimeError):
185+
except SubprocessCommandError as exc:
186+
_raise_if_ssh_host_key_error(url_path, exc)
187+
return []
188+
except RuntimeError:
130189
return []
131190

132191

@@ -176,7 +235,7 @@ def externals_from_url(url: str, revision: str = "") -> list[External]:
176235
if revision:
177236
cmd += ["--revision", revision]
178237
cmd += [url]
179-
result = run_on_cmdline(logger, cmd)
238+
result = run_on_cmdline(logger, cmd, env=_extend_env_for_non_interactive_mode())
180239
repo_root = SvnRepo.get_info_from_target(url)["Repository Root"]
181240
normalized = SvnRepo._normalize_url_prefix(result.stdout.decode(), url)
182241
return SvnRepo._parse_externals(normalized, repo_root)
@@ -292,9 +351,12 @@ def get_info_from_target(target: str = "") -> dict[str, str]:
292351
"""Get the info of the given target."""
293352
try:
294353
result = run_on_cmdline(
295-
logger, ["svn", "info", "--non-interactive", target.strip()]
354+
logger,
355+
["svn", "info", "--non-interactive", target.strip()],
356+
env=_extend_env_for_non_interactive_mode(),
296357
).stdout.decode()
297358
except SubprocessCommandError as exc:
359+
_raise_if_ssh_host_key_error(target, exc)
298360
if exc.stderr.startswith("svn: E170013"):
299361
raise RuntimeError(
300362
f">>>{exc.cmd}<<< failed!\n"
@@ -335,6 +397,7 @@ def get_last_changed_revision(target: str | Path) -> str:
335397
"last-changed-revision",
336398
target_str,
337399
],
400+
env=_extend_env_for_non_interactive_mode(),
338401
)
339402
.stdout.decode()
340403
.strip()
@@ -382,6 +445,7 @@ def export(url: str, rev: str = "", dst: str = ".") -> None:
382445
["svn", "export", "--non-interactive", "--force"]
383446
+ (["--revision", rev] if rev else [])
384447
+ [url, dst],
448+
env=_extend_env_for_non_interactive_mode(),
385449
)
386450

387451
@staticmethod
@@ -390,7 +454,9 @@ def files_in_path(url_path: str) -> list[str]:
390454
return [
391455
str(line)
392456
for line in run_on_cmdline(
393-
logger, ["svn", "list", "--non-interactive", url_path]
457+
logger,
458+
["svn", "list", "--non-interactive", url_path],
459+
env=_extend_env_for_non_interactive_mode(),
394460
)
395461
.stdout.decode()
396462
.splitlines()

example/dfetch.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ manifest:
77
default: true # Set it as default
88

99
- name: sourceforge
10-
url-base: svn://svn.code.sf.net/p/
10+
url-base: svn+ssh://svn.code.sf.net/p/
1111

1212
projects:
1313

tests/test_svn.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -520,3 +520,64 @@ 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",
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+
)
533+
def test_svn_remote_raises_hint_on_ssh_host_key_failure(method, url):
534+
stderr = "Host key verification failed."
535+
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
536+
mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1)
537+
538+
with pytest.raises(RuntimeError, match="known hosts"):
539+
getattr(SvnRemote(url), method)()
540+
541+
542+
def test_get_info_from_target_raises_hint_on_ssh_host_key_failure():
543+
stderr = "Host key verification failed."
544+
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
545+
mock_run.side_effect = SubprocessCommandError(["svn", "info"], "", stderr, 1)
546+
547+
with pytest.raises(RuntimeError, match="known hosts"):
548+
SvnRepo.get_info_from_target("svn+ssh://svn.code.sf.net/project")
549+
550+
551+
def test_ssh_hint_includes_hostname():
552+
stderr = "Host key verification failed."
553+
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
554+
mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1)
555+
556+
with pytest.raises(RuntimeError, match="svn.code.sf.net"):
557+
SvnRemote("svn+ssh://svn.code.sf.net/project").is_svn()
558+
559+
560+
def test_ssh_hint_includes_user_when_present_in_url():
561+
stderr = "Host key verification failed."
562+
with patch("dfetch.vcs.svn.run_on_cmdline") as mock_run:
563+
mock_run.side_effect = SubprocessCommandError(["svn"], "", stderr, 1)
564+
565+
with pytest.raises(RuntimeError, match="myuser@svn.code.sf.net"):
566+
SvnRemote("svn+ssh://myuser@svn.code.sf.net/project").is_svn()
567+
568+
569+
def test_svn_ssh_env_has_batch_mode():
570+
from dfetch.vcs.svn import _extend_env_for_non_interactive_mode
571+
572+
_extend_env_for_non_interactive_mode.cache_clear()
573+
env = _extend_env_for_non_interactive_mode()
574+
assert "BatchMode=yes" in env["SVN_SSH"]
575+
576+
577+
def test_svn_ssh_env_preserves_existing_batch_mode(monkeypatch):
578+
from dfetch.vcs.svn import _extend_env_for_non_interactive_mode
579+
580+
monkeypatch.setenv("SVN_SSH", "ssh -o BatchMode=yes -i /my/key")
581+
_extend_env_for_non_interactive_mode.cache_clear()
582+
env = _extend_env_for_non_interactive_mode()
583+
assert env["SVN_SSH"].count("BatchMode=yes") == 1

0 commit comments

Comments
 (0)