Skip to content

Commit cc9fc7e

Browse files
codexByron
authored andcommitted
fix: Validate submodule checkout and metadata paths before mutation
Submodule.add() could clone through a checkout symlink after module_exists() swallowed the validation error. Metadata paths had a similar gap: locally planted symlinks under .git/modules could redirect cloning, reconnecting, renaming, updating, or removing a submodule. Some failures were detected only after changing configuration or moving or removing checkout directories. Reuse the checkout component check for metadata paths and validate checkout paths in the shared clone helper, including legacy embedded repositories. Check .gitfiles, submodule configuration files, and the actual repository path named by a gitfile, which can differ from .git/modules/<name>. Reject symlinked .gitmodules files as well. Preflight move and rename sources and destinations before mutation, including the implicit metadata rename when a default-named submodule moves. Keep module_exists()'s boolean contract and the existing supported replacement of a leaf symlink during a move. Add 56 regression cases covering checkout and metadata links, dangling links, redirected gitfiles, legacy clone layouts, and rejected operations preserving external targets, configuration, the index, and an empty move destination. The initial 36 cases reproduced failures before the fix. These checks reject existing symlinks; they do not prevent concurrent filesystem replacement between validation and use. Validation: the submodule and diff suites passed with 157 passed, 3 skipped, and 1 expected failure. Test-process Git settings disabled commit signing, allowed local file transport, and selected the master default branch to match fixture assumptions. Ruff lint and formatting, mypy for the changed module, and git diff --check also passed.
1 parent a088524 commit cc9fc7e

2 files changed

Lines changed: 165 additions & 16 deletions

File tree

git/objects/submodule/base.py

Lines changed: 54 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -255,7 +255,7 @@ def _config_parser(
255255
# END handle parent_commit
256256
fp_module: Union[str, BytesIO]
257257
if not repo.bare and parent_matches_head and repo.working_tree_dir:
258-
fp_module = osp.join(repo.working_tree_dir, cls.k_modules_file)
258+
fp_module = cls._checked_abspath(repo.working_tree_dir, cls.k_modules_file)
259259
else:
260260
assert parent_commit is not None, "need valid parent_commit in bare repositories"
261261
try:
@@ -320,9 +320,9 @@ def _validated_name(cls, name: str) -> str:
320320
def _module_abspath(cls, parent_repo: "Repo", path: PathLike, name: str) -> PathLike:
321321
name = cls._validated_name(name)
322322
if cls._need_gitfile_submodules(parent_repo.git):
323-
return osp.join(parent_repo.git_dir, "modules", name)
323+
return cls._checked_abspath(parent_repo.git_dir, osp.join("modules", name))
324324
if parent_repo.working_tree_dir:
325-
return osp.join(parent_repo.working_tree_dir, path)
325+
return cls._checked_abspath(parent_repo.working_tree_dir, cls._to_relative_path(parent_repo, path))
326326
raise NotADirectoryError()
327327

328328
@classmethod
@@ -361,8 +361,13 @@ def _clone_repo(
361361
:param kwargs:
362362
Additional arguments given to :manpage:`git-clone(1)`.
363363
"""
364+
path = cls._to_relative_path(repo, path)
365+
if repo.working_tree_dir is None:
366+
raise NotADirectoryError("Submodules require a working tree")
367+
module_checkout_path = cls._checked_abspath(repo.working_tree_dir, path)
364368
module_abspath = cls._module_abspath(repo, path, name)
365-
module_checkout_path = module_abspath
369+
cls._checked_abspath(module_checkout_path, ".git")
370+
cls._checked_abspath(module_abspath, "config")
366371
if cls._need_gitfile_submodules(repo.git):
367372
if not allow_unsafe_options:
368373
Git.check_unsafe_options(Git._option_candidates([], kwargs), repo.unsafe_git_clone_options)
@@ -377,7 +382,6 @@ def _clone_repo(
377382
module_abspath_dir = osp.dirname(module_abspath)
378383
if not osp.isdir(module_abspath_dir):
379384
os.makedirs(module_abspath_dir)
380-
module_checkout_path = osp.join(repo.working_tree_dir, path) # type: ignore[arg-type]
381385

382386
if url.startswith("../"):
383387
remote_name = cast("RemoteReference", repo.active_branch.tracking_branch()).remote_name
@@ -423,16 +427,23 @@ def abspath(self) -> PathLike:
423427

424428
def _checkout_abspath(self, relative_path: PathLike, allow_final_symlink: bool = False) -> PathLike:
425429
"""Check a checkout path already normalized by :meth:`_to_relative_path`."""
426-
path = self.repo.working_tree_dir
427-
if path is None:
430+
return self._checked_abspath(self.repo.working_tree_dir, relative_path, allow_final_symlink)
431+
432+
@classmethod
433+
def _checked_abspath(
434+
cls, root: Union[PathLike, None], relative_path: PathLike, allow_final_symlink: bool = False
435+
) -> str:
436+
"""Reject symlinks below a trusted root before accessing submodule paths."""
437+
if root is None:
428438
raise NotADirectoryError("Submodules require a working tree")
429-
components = os.fspath(relative_path).split("/")
439+
path = os.fspath(root)
440+
components = to_native_path_linux(relative_path).split("/")
430441
for index, component in enumerate(components):
431-
path = join_path_native(path, component)
442+
path = os.fspath(join_path_native(path, component))
432443
if allow_final_symlink and index == len(components) - 1:
433444
break
434445
if osp.islink(path):
435-
raise ValueError("Submodule checkout path %r contains a symbolic link" % relative_path)
446+
raise ValueError("Submodule path %r contains a symbolic link" % relative_path)
436447
return path
437448

438449
@classmethod
@@ -458,14 +469,15 @@ def _write_git_file_and_module_config(cls, working_tree_dir: PathLike, module_ab
458469
:param module_abspath:
459470
Absolute path to the bare repository.
460471
"""
461-
git_file = osp.join(working_tree_dir, ".git")
472+
git_file = cls._checked_abspath(working_tree_dir, ".git")
473+
module_config = cls._checked_abspath(module_abspath, "config")
462474
rela_path = osp.relpath(module_abspath, start=working_tree_dir)
463475
if sys.platform == "win32" and osp.isfile(git_file):
464476
os.remove(git_file)
465477
with open(git_file, "wb") as fp:
466478
fp.write(("gitdir: %s" % rela_path).encode(defenc))
467479

468-
with GitConfigParser(osp.join(module_abspath, "config"), read_only=False, merge_includes=False) as writer:
480+
with GitConfigParser(module_config, read_only=False, merge_includes=False) as writer:
469481
writer.set_value(
470482
"core",
471483
"worktree",
@@ -576,6 +588,9 @@ def add(
576588
name,
577589
url="invalid-temporary",
578590
)
591+
cls._checked_abspath(repo.working_tree_dir, cls.k_modules_file)
592+
sm._checkout_abspath(path)
593+
cls._module_abspath(repo, path, name)
579594
if sm.exists():
580595
# Reretrieve submodule from tree.
581596
try:
@@ -1067,6 +1082,17 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10671082
return self
10681083
# END handle no change
10691084

1085+
if configuration:
1086+
self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file)
1087+
# Validate the source and both metadata destinations before removing anything.
1088+
cur_path = self.abspath
1089+
module_abspath = self._module_abspath(self.repo, self.path, self.name)
1090+
try:
1091+
self.module().close()
1092+
except InvalidGitRepositoryError:
1093+
pass
1094+
if self.path == self.name:
1095+
self._module_abspath(self.repo, module_checkout_path, os.fspath(module_checkout_path))
10701096
module_checkout_abspath = self._checkout_abspath(module_checkout_path, allow_final_symlink=True)
10711097
if osp.isfile(module_checkout_abspath):
10721098
raise ValueError("Cannot move repository onto a file: %s" % module_checkout_abspath)
@@ -1099,14 +1125,12 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10991125
# END handle module
11001126

11011127
# Move the module into place if possible.
1102-
cur_path = self.abspath
11031128
renamed_module = False
11041129
if module and osp.exists(cur_path):
11051130
os.renames(cur_path, module_checkout_abspath)
11061131
renamed_module = True
11071132

11081133
if osp.isfile(osp.join(module_checkout_abspath, ".git")):
1109-
module_abspath = self._module_abspath(self.repo, self.path, self.name)
11101134
self._write_git_file_and_module_config(module_checkout_abspath, module_abspath)
11111135
# END handle git file rewrite
11121136
# END move physical module
@@ -1201,6 +1225,8 @@ def remove(
12011225
# END handle parameters
12021226

12031227
self._validated_name(self.name)
1228+
if configuration:
1229+
self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file)
12041230
# Recursively remove children of this submodule.
12051231
nc = 0
12061232
for csm in self.children():
@@ -1460,6 +1486,9 @@ def rename(self, new_name: str) -> "Submodule":
14601486

14611487
self._validated_name(self.name)
14621488
self._validated_name(new_name)
1489+
destination_module_abspath = self._module_abspath(self.repo, self.path, new_name)
1490+
mod = self.module()
1491+
self._checked_abspath(self.repo.working_tree_dir, self.k_modules_file)
14631492

14641493
# .git/config
14651494
with self.repo.config_writer() as pw:
@@ -1476,9 +1505,7 @@ def rename(self, new_name: str) -> "Submodule":
14761505
self._name = new_name
14771506

14781507
# .git/modules
1479-
mod = self.module()
14801508
if mod.has_separate_working_tree():
1481-
destination_module_abspath = self._module_abspath(self.repo, self.path, new_name)
14821509
source_dir = mod.git_dir
14831510
# Let's be sure the submodule name is not so obviously tied to a directory.
14841511
if str(destination_module_abspath).startswith(str(mod.git_dir)):
@@ -1510,9 +1537,20 @@ def module(self) -> "Repo":
15101537
"""
15111538
self._validated_name(self.name)
15121539
module_checkout_abspath = self.abspath
1540+
module_abspath = self._module_abspath(self.repo, self.path, self.name)
1541+
self._checked_abspath(module_abspath, "config")
1542+
self._checked_abspath(module_checkout_abspath, ".git")
15131543
try:
15141544
repo = git.Repo(module_checkout_abspath)
15151545
if repo != self.repo:
1546+
# The gitfile can name a different repository than .git/modules/<name>.
1547+
# Validate its actual path too, including old-style embedded repositories.
1548+
try:
1549+
root = osp.commonpath([self.repo.git_dir, repo.git_dir])
1550+
except ValueError: # Separate Windows drives have no common path.
1551+
root = osp.splitdrive(repo.git_dir)[0] + osp.sep
1552+
self._checked_abspath(root, osp.relpath(repo.git_dir, root))
1553+
self._checked_abspath(repo.git_dir, "config")
15161554
return repo
15171555
# END handle repo uninitialized
15181556
except (InvalidGitRepositoryError, NoSuchPathError) as e:

test/test_submodule.py

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,117 @@ def test_move_leaf_symlink_compatibility(movable_submodule, tmp_path, kind):
176176
assert not target.exists()
177177

178178

179+
@pytest.mark.parametrize("leaf", [False, True])
180+
@pytest.mark.parametrize("dangling", [False, True])
181+
@pytest.mark.parametrize("operation", ["add", "clone"])
182+
@pytest.mark.parametrize("gitfile", [False, True])
183+
def test_clone_rejects_checkout_symlinks(movable_submodule, tmp_path, leaf, dangling, operation, gitfile):
184+
sm = movable_submodule
185+
root = Path(sm.repo.working_tree_dir)
186+
target = tmp_path / "outside"
187+
if not dangling:
188+
target.mkdir()
189+
(root / "link").symlink_to(target, target_is_directory=True)
190+
path = "link" if leaf else "link/new/module"
191+
before = _move_snapshot(sm)
192+
with mock.patch.object(Submodule, "_need_gitfile_submodules", return_value=gitfile):
193+
with pytest.raises(ValueError, match="contains a symbolic link"):
194+
if operation == "add":
195+
Submodule.add(sm.repo, "new", path, sm.url)
196+
else:
197+
Submodule._clone_repo(sm.repo, sm.url, path, "new")
198+
assert _move_snapshot(sm) == before
199+
assert not (Path(sm.repo.git_dir) / "modules/new").exists()
200+
assert not target.exists() if dangling else list(target.iterdir()) == []
201+
202+
203+
@pytest.mark.parametrize("leaf", [False, True])
204+
@pytest.mark.parametrize("operation", ["add", "rename", "move", "reconnect"])
205+
def test_submodule_rejects_metadata_destination_symlinks(movable_submodule, tmp_path, leaf, operation):
206+
sm = movable_submodule
207+
target = tmp_path / "outside"
208+
target.mkdir()
209+
modules = Path(sm.repo.git_dir) / "modules"
210+
(modules / "link").symlink_to(target, target_is_directory=True)
211+
name = "link" if leaf else "link/new/module"
212+
if operation == "move":
213+
sm.rename(sm.path) # Moving a default-named module also renames its metadata.
214+
before = _move_snapshot(sm)
215+
parent_config = Path(sm.repo.git_dir, "config").read_bytes()
216+
with pytest.raises(ValueError, match="contains a symbolic link"):
217+
if operation == "add":
218+
Submodule.add(sm.repo, name, "new", sm.url)
219+
elif operation == "rename":
220+
sm.rename(name)
221+
elif operation == "move":
222+
sm.move(name)
223+
else:
224+
Submodule(sm.repo, sm.binsha, name=name, path="new", url=sm.url).update(init=True)
225+
assert _move_snapshot(sm) == before
226+
assert Path(sm.repo.git_dir, "config").read_bytes() == parent_config
227+
assert list(target.iterdir()) == []
228+
assert not Path(sm.repo.working_tree_dir, "new").exists()
229+
230+
231+
@pytest.mark.parametrize(
232+
"link_kind", ["modules", "intermediate", "leaf", "gitfile", "config", "gitmodules", "alias", "checkout"]
233+
)
234+
@pytest.mark.parametrize("operation", ["update", "move", "rename", "remove"])
235+
def test_submodule_rejects_existing_metadata_symlinks(movable_submodule, tmp_path, link_kind, operation):
236+
sm = movable_submodule
237+
sm.rename("nested/module")
238+
root = Path(sm.repo.working_tree_dir)
239+
modules = Path(sm.repo.git_dir) / "modules"
240+
paths = {
241+
"modules": modules,
242+
"intermediate": modules / "nested",
243+
"leaf": modules / "nested/module",
244+
"gitfile": root / "module/.git",
245+
"config": modules / "nested/module/config",
246+
"gitmodules": root / ".gitmodules",
247+
"alias": modules / "alias",
248+
"checkout": root / "module",
249+
}
250+
path = paths[link_kind]
251+
target = tmp_path / "outside"
252+
if link_kind == "alias":
253+
target = modules / "nested"
254+
(root / "module/.git").write_text("gitdir: ../.git/modules/alias/module")
255+
else:
256+
path.rename(target)
257+
path.symlink_to(target, target_is_directory=target.is_dir())
258+
before = (
259+
{p.relative_to(target): p.read_bytes() for p in target.rglob("*") if p.is_file()}
260+
if target.is_dir()
261+
else target.read_bytes()
262+
)
263+
config = Path(sm.repo.git_dir, "config").read_bytes()
264+
index = Path(sm.repo.index.path).read_bytes()
265+
gitmodules = (root / ".gitmodules").read_bytes()
266+
(root / "moved").mkdir()
267+
with pytest.raises(ValueError, match="contains a symbolic link"):
268+
if operation == "update":
269+
sm.update()
270+
elif operation == "move":
271+
sm.move("moved")
272+
elif operation == "rename":
273+
sm.rename("renamed")
274+
else:
275+
sm.remove(force=True)
276+
after = (
277+
{p.relative_to(target): p.read_bytes() for p in target.rglob("*") if p.is_file()}
278+
if target.is_dir()
279+
else target.read_bytes()
280+
)
281+
assert after == before
282+
assert Path(sm.repo.git_dir, "config").read_bytes() == config
283+
assert Path(sm.repo.index.path).read_bytes() == index
284+
assert (root / ".gitmodules").read_bytes() == gitmodules
285+
assert (root / "module/file").read_text() == "content"
286+
assert path.is_symlink()
287+
assert (root / "moved").is_dir()
288+
289+
179290
class TestRootProgress(RootUpdateProgress):
180291
"""Just prints messages, for now without checking the correctness of the states"""
181292

0 commit comments

Comments
 (0)