Skip to content

Commit 5a1b6f3

Browse files
codexByron
authored andcommitted
Reject submodule move destinations through intermediate symlinks
Submodule.move() checked lexical containment but did not validate intermediate destination components before filesystem and repository updates. GHSA-gq48-pqfc-9p58 identifies the resulting checkout-path boundary violation. The new regression failed before the fix because move() returned successfully. Share the existing abspath component walk with move() and validate the normalized destination before any mutation, including configuration-only and module-only calls. Preserve the no-op early return and existing final-component symlink handling; abspath still rejects every symlink component. This addresses pre-existing links, not concurrent directory replacement races. The Git reference checkout at 1630431f326e15fcde608827b5ff38422528eb59 uses has_symlink_leading_path() in builtin/mv.c and tests rejection without index changes in t/t7001-mv.sh. The fix follows that intermediate-component rule while retaining GitPython leaf-link compatibility. Validation: the 30 new parameterized cases pass, covering relative and absolute destinations and link targets, internal and dangling links, all move flag combinations, unchanged repository state after rejection, ordinary and no-op moves, and leaf-link compatibility. The complete test/test_submodule.py suite passes: 75 passed, 3 skipped, 1 xfailed. Test-process commit.gpgsign=false avoids sandbox GPG failures. Ruff lint and format checks, mypy (45 source files), and git diff --check pass.
1 parent b62e91b commit 5a1b6f3

2 files changed

Lines changed: 124 additions & 5 deletions

File tree

git/objects/submodule/base.py

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -419,11 +419,20 @@ def abspath(self) -> PathLike:
419419
root = self.repo.working_tree_dir
420420
if root is None:
421421
return super().abspath
422-
path = root
423-
for component in os.fspath(self._to_relative_path(self.repo, self.path)).split("/"):
422+
return self._checkout_abspath(self._to_relative_path(self.repo, self.path))
423+
424+
def _checkout_abspath(self, relative_path: PathLike, allow_final_symlink: bool = False) -> PathLike:
425+
"""Check a checkout path already normalized by :meth:`_to_relative_path`."""
426+
path = self.repo.working_tree_dir
427+
if path is None:
428+
raise NotADirectoryError("Submodules require a working tree")
429+
components = os.fspath(relative_path).split("/")
430+
for index, component in enumerate(components):
424431
path = join_path_native(path, component)
432+
if allow_final_symlink and index == len(components) - 1:
433+
break
425434
if osp.islink(path):
426-
raise ValueError("Submodule checkout path %r contains a symbolic link" % self.path)
435+
raise ValueError("Submodule checkout path %r contains a symbolic link" % relative_path)
427436
return path
428437

429438
@classmethod
@@ -1039,7 +1048,8 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10391048
self
10401049
10411050
:raise ValueError:
1042-
If the module path existed and was not empty, or was a file.
1051+
If the module path existed and was not empty, was a file, or had a
1052+
symbolic link in an intermediate component.
10431053
10441054
:note:
10451055
Currently the method is not atomic, and it could leave the repository in an
@@ -1057,7 +1067,7 @@ def move(self, module_path: PathLike, configuration: bool = True, module: bool =
10571067
return self
10581068
# END handle no change
10591069

1060-
module_checkout_abspath = join_path_native(str(self.repo.working_tree_dir), module_checkout_path)
1070+
module_checkout_abspath = self._checkout_abspath(module_checkout_path, allow_final_symlink=True)
10611071
if osp.isfile(module_checkout_abspath):
10621072
raise ValueError("Cannot move repository onto a file: %s" % module_checkout_abspath)
10631073
# END handle target files

test/test_submodule.py

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,115 @@ def _patch_git_config(name, value):
5151
yield
5252

5353

54+
@pytest.fixture
55+
def movable_submodule(tmp_path):
56+
with git.Repo.init(tmp_path / "source") as source, git.Repo.init(tmp_path / "parent") as parent:
57+
(tmp_path / "source" / "file").write_text("content", encoding="utf-8")
58+
source.index.add(["file"])
59+
source.index.commit("Create source")
60+
with _patch_git_config("protocol.file.allow", "always"):
61+
submodule = parent.create_submodule("logical-name", "module", source.working_tree_dir)
62+
parent.index.commit("Create submodule")
63+
yield submodule
64+
65+
66+
def _move_snapshot(submodule):
67+
parent = submodule.repo
68+
with submodule.module() as module:
69+
config = Path(module.git_dir, "config").read_bytes()
70+
return (
71+
Path(parent.index.path).read_bytes(),
72+
Path(parent.working_tree_dir, ".gitmodules").read_bytes(),
73+
Path(submodule.abspath, ".git").read_bytes(),
74+
config,
75+
submodule.path,
76+
)
77+
78+
79+
@pytest.mark.parametrize("target_kind", ["relative", "absolute", "internal", "dangling"])
80+
@pytest.mark.parametrize("configuration,module", [(True, True), (False, True), (True, False)])
81+
@pytest.mark.parametrize("absolute_path", [False, True])
82+
def test_move_rejects_intermediate_symlink(
83+
movable_submodule, tmp_path, target_kind, configuration, module, absolute_path
84+
):
85+
submodule = movable_submodule
86+
parent = submodule.repo
87+
root = Path(parent.working_tree_dir)
88+
target = root / "target" if target_kind == "internal" else tmp_path / "outside"
89+
if target_kind != "dangling":
90+
target.mkdir()
91+
(root / "nested").mkdir()
92+
link = root / "nested" / "link"
93+
link.symlink_to(
94+
target if target_kind == "absolute" else os.path.relpath(target, link.parent), target_is_directory=True
95+
)
96+
parent.index.add(["nested/link"])
97+
parent.index.commit("Record layout")
98+
tree = parent.git.write_tree()
99+
before = _move_snapshot(submodule)
100+
destination = root / "nested/link/new/moved" if absolute_path else "nested/link/new/moved"
101+
102+
with pytest.raises(ValueError, match="contains a symbolic link"):
103+
submodule.move(destination, configuration=configuration, module=module)
104+
105+
assert _move_snapshot(submodule) == before
106+
assert parent.git.write_tree() == tree
107+
assert Path(submodule.abspath, "file").read_text(encoding="utf-8") == "content"
108+
if target_kind == "dangling":
109+
assert not target.exists()
110+
else:
111+
assert list(target.iterdir()) == []
112+
113+
114+
@pytest.mark.parametrize("absolute_path", [False, True])
115+
def test_move_normal_destination(movable_submodule, absolute_path):
116+
submodule = movable_submodule
117+
root = Path(submodule.repo.working_tree_dir)
118+
destination = root / "nested/moved" if absolute_path else "nested/moved"
119+
assert submodule.move(destination) is submodule
120+
assert Path(submodule.abspath, "file").read_text(encoding="utf-8") == "content"
121+
assert not (root / "module").exists()
122+
assert submodule.path == "nested/moved"
123+
submodule.repo.git.write_tree()
124+
before = _move_snapshot(submodule)
125+
assert submodule.move(destination) is submodule
126+
assert _move_snapshot(submodule) == before
127+
128+
129+
@pytest.mark.parametrize("kind", ["empty", "nonempty", "file", "dangling"])
130+
def test_move_leaf_symlink_compatibility(movable_submodule, tmp_path, kind):
131+
submodule = movable_submodule
132+
root = Path(submodule.repo.working_tree_dir)
133+
target = tmp_path / "outside"
134+
if kind in ("empty", "nonempty"):
135+
target.mkdir()
136+
if kind == "nonempty":
137+
(target / "keep").write_text("keep", encoding="utf-8")
138+
if kind == "file":
139+
target.write_text("keep", encoding="utf-8")
140+
destination = root / "destination"
141+
destination.symlink_to(target, target_is_directory=kind != "file")
142+
with pytest.raises(ValueError, match="contains a symbolic link"):
143+
Submodule(submodule.repo, Submodule.NULL_BIN_SHA, name="unused", path="destination").abspath
144+
before = _move_snapshot(submodule)
145+
if kind == "empty":
146+
assert submodule.move("destination") is submodule
147+
assert not destination.is_symlink()
148+
assert (destination / "file").is_file()
149+
assert list(target.iterdir()) == []
150+
else:
151+
with pytest.raises(OSError if kind == "dangling" else ValueError):
152+
submodule.move("destination")
153+
assert _move_snapshot(submodule) == before
154+
assert destination.is_symlink()
155+
if kind == "nonempty":
156+
assert (target / "keep").read_text(encoding="utf-8") == "keep"
157+
elif kind == "file":
158+
assert target.read_text(encoding="utf-8") == "keep"
159+
else:
160+
assert not target.exists()
161+
162+
54163
class TestRootProgress(RootUpdateProgress):
55164
"""Just prints messages, for now without checking the correctness of the states"""
56165

0 commit comments

Comments
 (0)