Skip to content

Commit 833c8d8

Browse files
committed
fix(submodule): resolve relative submodule URLs the way git does
`Submodule.update` resolved a `.gitmodules` URL starting with `./` or `../` with `urllib.parse.urljoin` against the superproject's `origin` URL, and `Submodule._clone_repo` appended a `../` URL to the tracking remote's URL with `os.path.join`. Neither follows git's `relative_url`, and `urljoin` only resolves against the schemes it knows: for an `ssh://` or `git://` origin it returns the relative URL unchanged, and for an scp-like `host:path` origin more than one `../` collapses to a bare path. `git clone` then received `./planted`, a path inside the superproject's checkout, where git resolves the same entry to `ssh://host/org/super.git/planted`. A submodule was therefore cloned from a directory committed by whoever authored the superproject, through git's local clone path: `git upload-pack` ran inside that directory and its objects were copied without the `transfer.fsckObjects` checks a transport fetch applies (an object that `git clone file://...` rejects as `badDate` is accepted this way). Relative submodule URLs are common in multi-repository setups, and the superproject is untrusted input to any service that updates submodules of repositories it is given. Add `resolve_relative_url`, a port of git's `relative_url`: each leading `../` strips one component of the remote URL, a path component first, then the scp-like host before the colon, and running out of components is an error; `./` prefixes are dropped and the rest is appended. It agrees with what `git submodule init` records over 272 remote and URL pairs, including the error cases. Use it in `Submodule.update`, `Submodule._clone_repo` (which now also resolves `./` URLs, previously passed to `git clone` verbatim), and the URL change path of `RootModule.update`, which created a remote from the raw relative URL and fetched it as a path relative to the module's working tree. The remote used for resolution is unchanged: `origin` in `update`, the tracking branch's remote in `_clone_repo`. Adds regression tests in `test/test_submodule.py`; the two behavioral ones fail before the change with `./planted` reaching the clone unchanged.
1 parent 563cb1f commit 833c8d8

4 files changed

Lines changed: 177 additions & 12 deletions

File tree

‎git/objects/submodule/base.py‎

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@
1212
import shlex
1313
import stat
1414
import sys
15-
import urllib.parse
1615
import uuid
1716
from io import BytesIO
1817
from pathlib import Path
@@ -58,7 +57,9 @@
5857
from .util import (
5958
SubmoduleConfigParser,
6059
find_first_remote_branch,
60+
is_relative_url,
6161
mkhead,
62+
resolve_relative_url,
6263
sm_name,
6364
sm_section,
6465
)
@@ -428,10 +429,10 @@ def _clone_repo(
428429
if not osp.isdir(module_abspath_dir):
429430
os.makedirs(module_abspath_dir)
430431

431-
if url.startswith("../"):
432+
if is_relative_url(url):
432433
remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name
433434
repo_remote_url = repo.remote(remote_name).url
434-
url = os.path.join(repo_remote_url, url)
435+
url = resolve_relative_url(repo_remote_url, url)
435436

436437
clone = git.Repo.clone_from(
437438
url,
@@ -982,13 +983,9 @@ def fetch_remotes(module_repo: "Repo") -> None:
982983
if not dry_run:
983984
if no_fetch:
984985
raise ValueError("Missing module at %r but fetching is disabled" % self.path) from None
985-
if self.url.startswith("."):
986-
url = urllib.parse.urljoin(self.repo.remotes.origin.url + "/", self.url)
987-
else:
988-
url = self.url
989986
mrepo = self._clone_repo(
990987
self.repo,
991-
url,
988+
self._resolved_url(),
992989
self.path,
993990
self.name,
994991
n=True,
@@ -1738,6 +1735,13 @@ def url(self) -> str:
17381735
""":return: The url to the repository our submodule's repository refers to"""
17391736
return self._url
17401737

1738+
def _resolved_url(self) -> str:
1739+
""":return: :attr:`url`, with a ``./`` or ``../`` prefix resolved against the
1740+
url of the superproject's ``origin`` remote the way git does"""
1741+
if not is_relative_url(self.url):
1742+
return self.url
1743+
return resolve_relative_url(self.repo.remotes.origin.url, self.url)
1744+
17411745
@property
17421746
def parent_commit(self) -> "Commit":
17431747
"""

‎git/objects/submodule/root.py‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -264,10 +264,14 @@ def update( # type: ignore[override]
264264
nn = "__new_origin__"
265265
smm = sm.module()
266266
rmts = smm.remotes
267+
# Relative urls are resolved against the superproject's remote,
268+
# which is what the module's remotes hold.
269+
sm_url = sm._resolved_url()
270+
psm_url = psm._resolved_url()
267271

268272
# Don't do anything if we already have the url we search in
269273
# place.
270-
if len([r for r in rmts if r.url == sm.url]) == 0:
274+
if len([r for r in rmts if r.url == sm_url]) == 0:
271275
progress.update(
272276
BEGIN | URLCHANGE,
273277
i,
@@ -278,7 +282,7 @@ def update( # type: ignore[override]
278282
if not dry_run:
279283
previous_remote = None
280284
for remote in rmts:
281-
if remote.url == psm.url:
285+
if remote.url == psm_url:
282286
previous_remote = remote
283287
break
284288
# END if urls match
@@ -305,10 +309,10 @@ def update( # type: ignore[override]
305309
# A new remote would have no cached refs. Preserve
306310
# the existing refs and tracking configuration for
307311
# offline updates instead of replacing the remote.
308-
previous_remote.set_url(git.Git.polish_url(sm.url, expand_vars=False))
312+
previous_remote.set_url(git.Git.polish_url(sm_url, expand_vars=False))
309313
else:
310314
assert nn not in [r.name for r in rmts]
311-
smr = smm.create_remote(nn, sm.url)
315+
smr = smm.create_remote(nn, sm_url)
312316
smr.fetch(progress=progress)
313317

314318
# If we have a tracking branch, it should be available

‎git/objects/submodule/util.py‎

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,13 @@
66
"sm_name",
77
"mkhead",
88
"find_first_remote_branch",
9+
"is_relative_url",
10+
"resolve_relative_url",
911
"SubmoduleConfigParser",
1012
]
1113

1214
from io import BytesIO
15+
import sys
1316
import weakref
1417

1518
import git
@@ -63,6 +66,97 @@ def find_first_remote_branch(remotes: Sequence["Remote"], branch_name: str) -> "
6366
raise InvalidGitRepositoryError("Didn't find remote branch '%r' in any of the given remotes" % branch_name)
6467

6568

69+
def _is_dir_sep(char: str) -> bool:
70+
return char == "/" or (sys.platform == "win32" and char == "\\")
71+
72+
73+
def _starts_with_dot_slash(url: str) -> bool:
74+
return len(url) >= 2 and url[0] == "." and _is_dir_sep(url[1])
75+
76+
77+
def _starts_with_dot_dot_slash(url: str) -> bool:
78+
return len(url) >= 3 and url[0:2] == ".." and _is_dir_sep(url[2])
79+
80+
81+
def _has_dos_drive_prefix(url: str) -> bool:
82+
return sys.platform == "win32" and len(url) >= 2 and url[0].isalpha() and url[1] == ":"
83+
84+
85+
def _is_absolute_path(url: str) -> bool:
86+
return (len(url) >= 1 and _is_dir_sep(url[0])) or _has_dos_drive_prefix(url)
87+
88+
89+
def _url_is_local_not_ssh(url: str) -> bool:
90+
colon = url.find(":")
91+
slash = url.find("/")
92+
return colon == -1 or (slash != -1 and slash < colon) or _has_dos_drive_prefix(url)
93+
94+
95+
def _find_last_dir_sep(url: str) -> int:
96+
for index in range(len(url) - 1, -1, -1):
97+
if _is_dir_sep(url[index]):
98+
return index
99+
return -1
100+
101+
102+
def is_relative_url(url: str) -> bool:
103+
""":return: ``True`` if `url` starts with ``./`` or ``../``, which git resolves
104+
against the superproject's remote rather than treating as a path"""
105+
return _starts_with_dot_slash(url) or _starts_with_dot_dot_slash(url)
106+
107+
108+
def resolve_relative_url(remote_url: str, url: str) -> str:
109+
"""Resolve a submodule URL that starts with ``./`` or ``../`` against the
110+
superproject's remote URL, the way git's ``relative_url`` does.
111+
112+
Git treats a relative submodule URL like a relative directory: each leading
113+
``../`` removes the last component of `remote_url`, whether that is a path
114+
component, the host of a URL, or the host of an scp-like ``host:path`` address.
115+
The remainder is appended with ``/``, or with ``:`` once an scp-like host was
116+
reached. A URL that is not relative is returned unchanged.
117+
118+
:raise ValueError:
119+
If more components are stripped than `remote_url` has, which git rejects with
120+
"cannot strip one component off url".
121+
"""
122+
if not is_relative_url(url):
123+
return url
124+
125+
remote = remote_url
126+
if remote and _is_dir_sep(remote[-1]):
127+
remote = remote[:-1]
128+
129+
is_relative = _url_is_local_not_ssh(remote) and not _is_absolute_path(remote)
130+
if is_relative and not _starts_with_dot_slash(remote) and not _starts_with_dot_dot_slash(remote):
131+
remote = "./" + remote
132+
133+
colonsep = False
134+
while url:
135+
if _starts_with_dot_dot_slash(url):
136+
url = url[3:]
137+
separator = _find_last_dir_sep(remote)
138+
if separator != -1:
139+
remote = remote[:separator]
140+
elif ":" in remote:
141+
remote = remote[: remote.rfind(":")]
142+
colonsep = True
143+
elif is_relative or remote == ".":
144+
raise ValueError("cannot strip one component off url %r" % remote)
145+
else:
146+
remote = "."
147+
elif _starts_with_dot_slash(url):
148+
url = url[2:]
149+
else:
150+
break
151+
152+
resolved = remote + (":" if colonsep else "/") + url
153+
if url.endswith("/"):
154+
resolved = resolved[:-1]
155+
if _starts_with_dot_slash(resolved):
156+
resolved = resolved[2:]
157+
return resolved
158+
159+
66160
# } END utilities
67161

68162
# { Classes

‎test/test_submodule.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
)
2727
from git.objects.submodule.base import Submodule
2828
from git.objects.submodule.root import RootModule, RootUpdateProgress
29+
from git.objects.submodule.util import resolve_relative_url
2930
from git.repo.fun import find_submodule_git_dir, touch
3031
from git.util import HIDE_WINDOWS_KNOWN_ERRORS, cwd, join_path_native, to_native_path_linux
3132

@@ -2416,3 +2417,65 @@ def test_submodule_update_relative_url(self, rwdir):
24162417

24172418
has_module = any(sm.name == "module" for sm in cloned_repo.submodules)
24182419
assert has_module, "Relative submodule was not updated properly"
2420+
2421+
def test_resolve_relative_url_like_git(self):
2422+
# Expected values are what `git submodule init` records for the same pair.
2423+
cases = [
2424+
("ssh://host/org/super.git", "./sub", "ssh://host/org/super.git/sub"),
2425+
("ssh://host/org/super.git", "../sub", "ssh://host/org/sub"),
2426+
("ssh://host:22/org/super.git", "../sub", "ssh://host:22/org/sub"),
2427+
("git@host:org/super.git", "./sub", "git@host:org/super.git/sub"),
2428+
("git@host:org/super.git", "../sub", "git@host:org/sub"),
2429+
("git@host:org/super.git", "../../sub", "git@host:sub"),
2430+
("https://host/org/super.git", "./sub/", "https://host/org/super.git/sub"),
2431+
("https://host/org/super.git/", "././sub", "https://host/org/super.git/sub"),
2432+
("https://host/org/super.git", ".././sub", "https://host/org/sub"),
2433+
("https://host/org/super.git", "../../../sub", "https://sub"),
2434+
("file:///srv/git/super.git", "../sub", "file:///srv/git/sub"),
2435+
("/srv/git/super", "../sub", "/srv/git/sub"),
2436+
("/srv/git/super", "../../../../sub", "sub"),
2437+
("//share/super", "../../sub", "//sub"),
2438+
("foo/bar", "../sub", "foo/sub"),
2439+
("./foo", "../sub", "sub"),
2440+
("../foo/bar", "../sub", "../foo/sub"),
2441+
("ssh://host/org/super.git", ".sub", ".sub"),
2442+
("ssh://host/org/super.git", "https://other/sub", "https://other/sub"),
2443+
]
2444+
for remote_url, url, expected in cases:
2445+
assert resolve_relative_url(remote_url, url) == expected, (remote_url, url)
2446+
2447+
for remote_url, url in [("foo", "../../sub"), ("git@host:super.git", "../../../sub"), ("../foo", "../../sub")]:
2448+
with self.assertRaisesRegex(ValueError, "cannot strip one component"):
2449+
resolve_relative_url(remote_url, url)
2450+
2451+
@with_rw_directory
2452+
def test_update_resolves_relative_url_against_remote(self, rwdir):
2453+
parent = git.Repo.init(osp.join(rwdir, "parent"))
2454+
parent.create_remote("origin", "ssh://git@host/org/super.git")
2455+
os.mkdir(osp.join(parent.working_tree_dir, "planted"))
2456+
2457+
for url, expected in [
2458+
("./planted", "ssh://git@host/org/super.git/planted"),
2459+
("../planted", "ssh://git@host/org/planted"),
2460+
]:
2461+
submodule = Submodule(parent, Submodule.NULL_BIN_SHA, name="module", path="module", url=url)
2462+
with mock.patch.object(Submodule, "_clone_repo", side_effect=AssertionError("stop")) as clone_repo:
2463+
with pytest.raises(AssertionError, match="stop"):
2464+
submodule.update(init=True)
2465+
assert clone_repo.call_args.args[1] == expected
2466+
2467+
@with_rw_directory
2468+
def test_clone_repo_resolves_relative_url_against_tracking_remote(self, rwdir):
2469+
parent = git.Repo.init(osp.join(rwdir, "parent"))
2470+
parent.index.commit("initial")
2471+
parent.create_remote("origin", "git@host:org/super.git")
2472+
parent.head.reference.set_tracking_branch(git.RemoteReference(parent, "refs/remotes/origin/main"))
2473+
2474+
for url, expected in [
2475+
("./planted", "git@host:org/super.git/planted"),
2476+
("../planted", "git@host:org/planted"),
2477+
]:
2478+
with mock.patch.object(git.Repo, "clone_from", side_effect=AssertionError("stop")) as clone_from:
2479+
with pytest.raises(AssertionError, match="stop"):
2480+
Submodule._clone_repo(parent, url, "module", "module")
2481+
assert clone_from.call_args.args[0] == expected

0 commit comments

Comments
 (0)