Skip to content

fix(submodule): resolve relative submodule URLs the way git does - #2289

Open
Keerthana-64 wants to merge 2 commits into
gitpython-developers:mainfrom
Keerthana-64:submodule-relative-url
Open

Keerthana-64 wants to merge 2 commits into
gitpython-developers:mainfrom
Keerthana-64:submodule-relative-url

Conversation

@Keerthana-64

Copy link
Copy Markdown
Contributor

Submodule.update resolves a ./ or ../ submodule URL with urllib.parse.urljoin against the origin URL, and Submodule._clone_repo joins a ../ URL onto the tracking remote's URL with os.path.join. urljoin only resolves against the schemes it knows, so for an ssh:// or git:// superproject the relative URL comes back unchanged and git clone is handed ./planted, a path inside the superproject's checkout; with an scp-like host:path origin, ../../planted collapses to the bare path planted. For the same .gitmodules entry, git submodule init records ssh://host/org/super.git/planted:

origin='ssh://git@git.example.com/org/super.git'  git        -> 'ssh://git@git.example.com/org/super.git/planted'
origin='ssh://git@git.example.com/org/super.git'  GitPython  -> cloned from '<superproject>/./planted'

The superproject's author controls both .gitmodules and the tree, so a committed directory laid out as a bare repository becomes the submodule's source. That is git's local clone path: git upload-pack runs inside the attacker's directory, and the objects are copied without the transfer.fsckObjects checks a transport fetch applies (a commit that git clone file://... refuses as badDate is accepted). Relative URLs are common in multi-repository setups, and anything that updates submodules of repositories it is given treats the superproject as untrusted.

resolve_relative_url in git/objects/submodule/util.py is a port of git's relative_url: each ../ strips one component of the remote URL, a path component first and then the scp-like host before the colon, running out of components is an error, ./ is dropped and the remainder appended. Checked against what git submodule init writes to submodule.<name>.url on git 2.54 for 272 remote and URL pairs (ssh://, host:path, https://, file://, //share, absolute and relative paths, trailing slashes, .sub, ..//sub), with no differences including the error cases. It replaces urljoin in Submodule.update, os.path.join in _clone_repo (which now also resolves ./, previously passed to git clone verbatim), and the raw relative URL that the URL change path of RootModule.update gave create_remote and set_url. The remote each site resolves against is unchanged. The two behavioral tests in test/test_submodule.py fail before the change with ./planted reaching the clone unchanged; test_submodule.py passes, and ruff, mypy and basedpyright --warnings report nothing new.

`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.
Byron pushed a commit to GitoxideLabs/gitoxide that referenced this pull request Oct 16, 2026
Relative submodule URLs in `.gitmodules` refer to the superproject's
upstream, even when that upstream uses `ssh://`, `git://`, or SCP syntax.
Passing the existing `Submodule::url()` result directly to a clone treats
`./` and `../` entries as local paths instead. GitPython's corresponding
fix prompted this investigation:
gitpython-developers/GitPython#2289

Document `url()` as the raw configured accessor and add `resolved_url()`
for cloning or fetching. Port Git's byte-oriented `relative_url()` rules
from `remote.c`, select the current branch's remote or the default remote,
and fall back to the superproject itself when the selected URL is missing.
Make local paths absolute so the result does not depend on the caller's
working directory. Honor configuration overrides as initialized URLs,
avoiding a second remote-relative resolution of local URLs written by
`git submodule init`.

Validate transport and local URLs, traversal errors, default-remote
selection, initialized overrides, unborn and bare repositories, and
non-UTF-8 local paths in disposable repositories. Compare resolution with
`git submodule init`; include native-separator coverage for Windows.

Validation: 34 submodule tests pass, the four new tests pass with SHA-256
fixtures, focused Clippy passes with warnings denied, and the
`sha256,attributes` build passes. Documentation builds; strict rustdoc
checking encounters the existing broken `Self::with_fetch_options` link
in `gix/src/clone/access.rs`.
I don't really know what I am doing, rubber-stamped the tests, and
really just wanted to get it done, knowing that the next version
removes the custom submodule implementation entirely in favor of
calling out to Git.

<!-- agent -->

- [P2] Fall back when a relative URL has no tracking remote —
  git/objects/submodule/base.py:432-435 For a locally initialized
  superproject without a tracking branch, `create_submodule(...,
  url='./source')` now enters this block and dereferences `None`, raising
  `AttributeError` even when `./source` is a valid repository. This addition
  succeeded before the patch; detached checkouts also fail at `active_branch`.
  Handle missing tracking branches by falling back to `origin` or the
  superproject directory, as Git does.

- [P2] Resolve relative submodule URLs only once —
  git/objects/submodule/base.py:986-988 When `origin` is a local relative
  URL such as `../repos/super.git` and the branch tracks that remote,
  `_resolved_url()` turns `../child.git` into `../repos/child.git`. Because
  that result still starts with `../`, `_clone_repo()` resolves it again,
  producing `../repos/repos/child.git`. This can fail cloning or select the
  wrong repository. Ensure URLs already resolved by `update()` bypass the second
  resolution.

- [P2] Defer old-URL resolution until a remote change is needed —
  git/objects/submodule/root.py:269-270 For an initialized superproject
  without `origin`, changing `.gitmodules` from `../source` to the absolute
  URL already used by the submodule's remote now raises `AttributeError` in
  `psm._resolved_url()`. Previously, the existing-remote check skipped URL
  handling and the update completed. Resolve the previous URL only after
  checking whether the new URL already matches a remote, so updates requiring no
  remote adjustment do not fail.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants