From 9826045fb9e0bfc835d98618039c7ab370dcbe56 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 17:24:07 +0000 Subject: [PATCH 1/3] Fix SSH shorthand URLs joined with '/' instead of ':' (#1247) When `url-base` is an SSH SCP-style shorthand like `git@git.mycompany.com`, dfetch was joining it with `repo-path` using '/' (producing the invalid `git@git.mycompany.com/my-repo.git`) instead of ':' (the correct `git@git.mycompany.com:my-repo.git`). Add `_is_ssh_shorthand` helper to detect these URLs (has '@', no '://'), use ':' as separator in `remote_url`, and fix `set_remote` to strip both '/' and ':' separators when decomposing a full SSH URL against a remote base. https://claude.ai/code/session_01MhZACpoBQKKtSFVwjZcuo7 --- CHANGELOG.rst | 1 + dfetch/manifest/project.py | 12 +++++++++--- tests/test_project_entry.py | 25 +++++++++++++++++++++++++ 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index bafba0219..e496345fc 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -21,6 +21,7 @@ Release 0.14.0 (unreleased) * Prevent SSH command injection (#1152) * Allow manifests with no ``projects`` key so ``dfetch add`` can bootstrap empty manifest (#1197) * Fix ``ValueError`` when generating a PackageURL (e.g. for an SBOM) from an empty or path-only remote URL +* Fix SSH shorthand URLs (``git@host:path``) being incorrectly joined with ``/`` when used as ``url-base`` with ``repo-path`` (#1247) Release 0.13.0 (released 2026-03-30) ==================================== diff --git a/dfetch/manifest/project.py b/dfetch/manifest/project.py index 5cb0c0185..5e5c155eb 100644 --- a/dfetch/manifest/project.py +++ b/dfetch/manifest/project.py @@ -354,6 +354,11 @@ def plaintext_warning(url: str) -> str: ) +def _is_ssh_shorthand(url: str) -> bool: + """Return True if *url* is an SSH SCP-style shorthand (``git@host``, no scheme).""" + return "@" in url and "://" not in url + + @dataclass class Integrity: """Integrity verification data for an archive dependency. @@ -461,7 +466,7 @@ def set_remote(self, remote: Remote) -> None: self._remote_obj = remote self._remote = remote.name if self._url.startswith(remote.url): - self._repo_path = self._url.replace(remote.url, "").strip("/") + self._repo_path = self._url[len(remote.url) :].lstrip("/:") self._url = "" @property @@ -487,8 +492,9 @@ def remote_url(self) -> str: if self._url: return self._url if self._remote_obj: - urls = [self._remote_obj.url.strip("/"), self._repo_path] - return "/".join(urls).strip("/") + base = self._remote_obj.url.strip("/") + separator = ":" if _is_ssh_shorthand(base) else "/" + return (base + separator + self._repo_path).strip("/") return "" @property diff --git a/tests/test_project_entry.py b/tests/test_project_entry.py index 0b560691c..856ff22e3 100644 --- a/tests/test_project_entry.py +++ b/tests/test_project_entry.py @@ -6,6 +6,7 @@ import pytest from dfetch.manifest.project import ProjectEntry +from dfetch.manifest.remote import Remote def test_projectentry_name(): @@ -49,3 +50,27 @@ def test_projectentry_as_str(): str(ProjectEntry({"name": "SomeProject"})) == "SomeProject latest SomeProject" ) + + +def test_remote_url_ssh_shorthand_uses_colon_separator(): + entry = ProjectEntry({"name": "myrepo", "repo-path": "myorg/myrepo.git"}) + entry.set_remote(Remote({"name": "corp", "url-base": "git@git.mycompany.com"})) + assert entry.remote_url == "git@git.mycompany.com:myorg/myrepo.git" + + +def test_remote_url_https_base_uses_slash_separator(): + entry = ProjectEntry({"name": "myrepo", "repo-path": "myorg/myrepo"}) + entry.set_remote(Remote({"name": "corp", "url-base": "https://github.com"})) + assert entry.remote_url == "https://github.com/myorg/myrepo" + + +def test_set_remote_strips_colon_prefix_from_ssh_url(): + entry = ProjectEntry({"name": "myrepo", "url": "git@git.mycompany.com:my-repo.git"}) + entry.set_remote(Remote({"name": "corp", "url-base": "git@git.mycompany.com"})) + assert entry.remote_url == "git@git.mycompany.com:my-repo.git" + + +def test_set_remote_strips_slash_prefix_from_https_url(): + entry = ProjectEntry({"name": "myrepo", "url": "https://github.com/org/repo"}) + entry.set_remote(Remote({"name": "gh", "url-base": "https://github.com"})) + assert entry.remote_url == "https://github.com/org/repo" From 9018cc6c465931eb85743e96eeddc3a2905e4918 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Jun 2026 17:44:46 +0000 Subject: [PATCH 2/3] Guard against trailing separator when repo-path is empty When _repo_path is empty, the previous code emitted a trailing ':' for SSH shorthand bases (strip("/") only removes slashes, not colons). Return base directly when there is no repo-path to join. Also add edge-case tests for trailing slash in url-base and empty repo-path. https://claude.ai/code/session_01MhZACpoBQKKtSFVwjZcuo7 --- dfetch/manifest/project.py | 2 ++ tests/test_project_entry.py | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/dfetch/manifest/project.py b/dfetch/manifest/project.py index 5e5c155eb..a65ed22be 100644 --- a/dfetch/manifest/project.py +++ b/dfetch/manifest/project.py @@ -493,6 +493,8 @@ def remote_url(self) -> str: return self._url if self._remote_obj: base = self._remote_obj.url.strip("/") + if not self._repo_path: + return base separator = ":" if _is_ssh_shorthand(base) else "/" return (base + separator + self._repo_path).strip("/") return "" diff --git a/tests/test_project_entry.py b/tests/test_project_entry.py index 856ff22e3..b7555513a 100644 --- a/tests/test_project_entry.py +++ b/tests/test_project_entry.py @@ -74,3 +74,15 @@ def test_set_remote_strips_slash_prefix_from_https_url(): entry = ProjectEntry({"name": "myrepo", "url": "https://github.com/org/repo"}) entry.set_remote(Remote({"name": "gh", "url-base": "https://github.com"})) assert entry.remote_url == "https://github.com/org/repo" + + +def test_remote_url_with_trailing_slash_in_base(): + entry = ProjectEntry({"name": "myrepo", "repo-path": "myorg/myrepo"}) + entry.set_remote(Remote({"name": "corp", "url-base": "https://github.com/"})) + assert entry.remote_url == "https://github.com/myorg/myrepo" + + +def test_remote_url_with_empty_repo_path(): + entry = ProjectEntry({"name": "myrepo"}) + entry.set_remote(Remote({"name": "corp", "url-base": "git@git.mycompany.com"})) + assert entry.remote_url == "git@git.mycompany.com" From eeccb343c5fe55b0cb1ca2679b3b622061199ceb Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 4 Jun 2026 06:26:47 +0000 Subject: [PATCH 3/3] Fix _is_ssh_shorthand matching path-prefixed bases like git@host:org A url-base such as 'git@host:org' already contains ':' as the SCP separator between host and path; treating it as a host-only shorthand caused remote_url to produce a double colon ('git@host:org:repo.git'). Add ':' not in url to _is_ssh_shorthand so only pure host shorthands (e.g. 'git@host') get ':' as separator; bases that already carry a path component continue to use '/'. https://claude.ai/code/session_01MhZACpoBQKKtSFVwjZcuo7 --- dfetch/manifest/project.py | 4 ++-- tests/test_project_entry.py | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/dfetch/manifest/project.py b/dfetch/manifest/project.py index a65ed22be..e3e8a2817 100644 --- a/dfetch/manifest/project.py +++ b/dfetch/manifest/project.py @@ -355,8 +355,8 @@ def plaintext_warning(url: str) -> str: def _is_ssh_shorthand(url: str) -> bool: - """Return True if *url* is an SSH SCP-style shorthand (``git@host``, no scheme).""" - return "@" in url and "://" not in url + """Return True if *url* is an SSH SCP-style host-only shorthand (``git@host``, no scheme, no path separator).""" + return "@" in url and "://" not in url and ":" not in url @dataclass diff --git a/tests/test_project_entry.py b/tests/test_project_entry.py index b7555513a..c8f2ca2d4 100644 --- a/tests/test_project_entry.py +++ b/tests/test_project_entry.py @@ -86,3 +86,9 @@ def test_remote_url_with_empty_repo_path(): entry = ProjectEntry({"name": "myrepo"}) entry.set_remote(Remote({"name": "corp", "url-base": "git@git.mycompany.com"})) assert entry.remote_url == "git@git.mycompany.com" + + +def test_remote_url_ssh_base_with_path_prefix_uses_slash_separator(): + entry = ProjectEntry({"name": "myrepo", "repo-path": "repo.git"}) + entry.set_remote(Remote({"name": "corp", "url-base": "git@host:org"})) + assert entry.remote_url == "git@host:org/repo.git"