Skip to content

Commit 999c765

Browse files
committed
fix(util): reject symbolic links that alias .gitmodules
`_validate_repo_path` mirrors Git's `verify_path` for tree and index entry paths, but it only ever saw the path. Git's check there is mode dependent: an entry that makes `.gitmodules` a symbolic link is refused, because the submodule configuration would then be read through the link, from outside the repository. That is why `git update-index --add --cacheinfo 120000,<sha>,.gitmodules` fails with `Invalid path`, `git read-tree` fails with `invalid path`, and `git fsck --strict` reports `gitmodulesSymlink`. GitPython accepted such an entry in both directions. `IndexFile.add` with a `BaseIndexEntry` of mode `120000` and path `.gitmodules` was stored, `write_tree` serialized the tree, and `IndexFile.commit` wrote a commit Git refuses to read back and a server with `transfer.fsckObjects` set rejects. Coming the other way, `read_cache` and `tree_entries_from_data` accepted the same entry out of an untrusted repository's index or tree. `_validate_repo_path` now takes the entry mode and, for a symbolic link, rejects every spelling Git recognizes: `.gitmodules` with trailing spaces or periods, the HFS form with ignorable code points removed, and the NTFS short names `gitmod~1` through `gitmod~4` and `gi7eba~1` through `gi7eba~9`. The mode is passed at the boundaries that have one: `write_cache`, `read_cache`, `write_tree_from_cache`, `_tree_entry_to_baseindexentry`, `IndexFile._preprocess_add_items`, `IndexFile.add`, `tree_to_stream`, `tree_entries_from_data` and `TreeModifier.add`. Paths reached without a mode, such as the directories walked by `IndexFile._iter_expand_paths`, keep their previous behavior, and a regular file named `.gitmodules` stays valid. Checked against `git update-index --add --cacheinfo` on git 2.52.0 for modes `100644`, `120000`, `160000` and `40000` over the alias corpus: no path is left that Git rejects and GitPython accepts. Like the existing `.git` rule the name is tested per component, so a link below a directory spelled like one of those aliases is refused as well, which Git happens to allow. Adds regression tests in `test/test_index.py` and `test/test_tree.py`; `mypy`, `basedpyright --warnings` and `ruff` are clean.
1 parent 1af7ce6 commit 999c765

7 files changed

Lines changed: 81 additions & 14 deletions

File tree

‎git/index/base.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -729,7 +729,7 @@ def _preprocess_add_items(
729729
raise TypeError("Invalid Type: %r" % item)
730730
# END for each item
731731
for entry in entries:
732-
_validate_repo_path(entry.path)
732+
_validate_repo_path(entry.path, entry.mode)
733733
return paths, entries
734734

735735
def _store_path(self, filepath: PathLike, fprogress: Callable) -> BaseIndexEntry:
@@ -1026,7 +1026,7 @@ def handle_null_entries(self: "IndexFile") -> None:
10261026
# FINALIZE
10271027
# Add the new entries to this instance.
10281028
for entry in entries_added:
1029-
_validate_repo_path(entry.path)
1029+
_validate_repo_path(entry.path, entry.mode)
10301030
for entry in entries_added:
10311031
self.entries[(entry.path, 0)] = IndexEntry.from_base(entry)
10321032

‎git/index/fun.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -279,7 +279,7 @@ def write_cache(
279279

280280
# Body
281281
for entry in entries:
282-
_validate_repo_path(entry.path)
282+
_validate_repo_path(entry.path, entry.mode)
283283
beginoffset = tell()
284284
write(entry.ctime_bytes) # ctime
285285
write(entry.mtime_bytes) # mtime
@@ -394,7 +394,7 @@ def read_cache(
394394
if terminator != b"\0":
395395
raise ValueError("Unterminated index entry path")
396396
path = path_bytes.decode(defenc)
397-
_validate_repo_path(path)
397+
_validate_repo_path(path, mode)
398398

399399
real_size = (tell() - beginoffset + 7) & ~7
400400
padding_size = beginoffset + real_size - tell()
@@ -462,7 +462,7 @@ def write_tree_from_cache(
462462
"""
463463
if si == 0:
464464
for entry in entries[sl]:
465-
_validate_repo_path(entry.path)
465+
_validate_repo_path(entry.path, entry.mode)
466466
tree_items: List["TreeCacheTup"] = []
467467

468468
ci = sl.start
@@ -510,7 +510,7 @@ def write_tree_from_cache(
510510

511511

512512
def _tree_entry_to_baseindexentry(tree_entry: "TreeCacheTup", stage: int) -> BaseIndexEntry:
513-
_validate_repo_path(tree_entry[2])
513+
_validate_repo_path(tree_entry[2], tree_entry[1])
514514
return BaseIndexEntry((tree_entry[1], tree_entry[0], stage << CE_STAGESHIFT, tree_entry[2]))
515515

516516

‎git/objects/fun.py‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,11 @@
3939
# ---------------------------------------------------
4040

4141

42-
def _validate_tree_entry_name(name: str) -> None:
42+
def _validate_tree_entry_name(name: str, mode: Union[int, None] = None) -> None:
4343
if "/" in name:
4444
raise ValueError("Tree entry names must not contain '/' characters")
4545
# A tree name is a component, not a rooted path; a colon cannot select a drive.
46-
_validate_repo_path("tree/" + name)
46+
_validate_repo_path("tree/" + name, mode)
4747

4848

4949
def tree_to_stream(entries: Sequence[EntryTup], write: Callable[["ReadableBuffer"], Union[int, None]]) -> None:
@@ -82,7 +82,7 @@ def tree_to_stream(entries: Sequence[EntryTup], write: Callable[["ReadableBuffer
8282
name_bytes = name.encode(defenc)
8383
else:
8484
name_bytes = name # type: ignore[unreachable] # check runtime types - is always str?
85-
_validate_tree_entry_name(safe_decode(name_bytes))
85+
_validate_tree_entry_name(safe_decode(name_bytes), mode)
8686
write(b"".join((mode_str, b" ", name_bytes, b"\0", binsha)))
8787
# END for each item
8888

@@ -112,7 +112,7 @@ def tree_entries_from_data(data: bytes) -> List[EntryTup]:
112112
if name_end < 0 or name_end + 21 > len(data):
113113
raise ValueError("Truncated tree entry")
114114
name = safe_decode(bytes(data[mode_end + 1 : name_end]))
115-
_validate_tree_entry_name(name)
115+
_validate_tree_entry_name(name, mode)
116116
offset = name_end + 21
117117
out.append((bytes(data[name_end + 1 : offset]), mode, name))
118118
return out

‎git/objects/tree.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,7 +110,7 @@ def add(self, sha: bytes, mode: int, name: str, force: bool = False) -> "TreeMod
110110
:return:
111111
self
112112
"""
113-
_validate_tree_entry_name(name)
113+
_validate_tree_entry_name(name, mode)
114114
if (mode >> 12) not in Tree._map_id_to_type:
115115
raise ValueError("Invalid object type according to mode %o" % mode)
116116

‎git/util.py‎

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,12 +385,25 @@ def _to_relative_path(root: PathLike, path: PathLike) -> str:
385385
"", "", "\u200c\u200d\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u206a\u206b\u206c\u206d\u206e\u206f\ufeff"
386386
)
387387

388+
# Windows derives an 8.3 short name for ".gitmodules" from its first six
389+
# characters followed by "~1" through "~4", and from a hashed stem once those
390+
# are taken. Git recognizes both spellings (is_ntfs_dotgitmodules in path.c).
391+
_NTFS_DOTGITMODULES_SHORT_NAMES = frozenset(
392+
["gitmod~%d" % index for index in range(1, 5)] + ["gi7eba~%d" % index for index in range(1, 10)]
393+
)
394+
388395

389-
def _validate_repo_path(path: PathLike) -> None:
396+
def _validate_repo_path(path: PathLike, mode: Union[int, None] = None) -> None:
390397
"""Reject unsafe tree/index paths without normalizing away their components.
391398
392399
Protect Git metadata aliases on NTFS and HFS even when writing on another
393400
platform. Other POSIX filename characters, including newlines, remain valid.
401+
402+
:param mode:
403+
Mode of the index or tree entry the path belongs to, where one is known.
404+
Git refuses a symbolic link that aliases ``.gitmodules``, since the
405+
submodule configuration would then be read through the link, so that
406+
name is only rejected once the mode says the entry is a link.
394407
"""
395408
name = os.fspath(path)
396409
if not name or "\0" in name or ntpath.splitdrive(name)[0] or name.startswith("/"):
@@ -406,6 +419,11 @@ def _validate_repo_path(path: PathLike) -> None:
406419
hfs_name = part.translate(_HFS_IGNORABLES).lower()
407420
if ntfs_name in (".git", "git~1") or hfs_name == ".git":
408421
raise ValueError("Repository path aliases Git metadata: %r" % name)
422+
aliases_gitmodules = (
423+
ntfs_name == ".gitmodules" or hfs_name == ".gitmodules" or ntfs_name in _NTFS_DOTGITMODULES_SHORT_NAMES
424+
)
425+
if aliases_gitmodules and mode is not None and stat.S_ISLNK(mode):
426+
raise ValueError("Symbolic link aliases the submodule configuration: %r" % name)
409427

410428

411429
def assure_directory_exists(path: PathLike, is_file: bool = False) -> bool:

‎test/test_index.py‎

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -189,10 +189,10 @@ def _make_hook(git_dir, name, content, make_exec=True):
189189
return hp
190190

191191

192-
def _raw_index(path):
192+
def _raw_index(path, mode=0o100644):
193193
"""Build an index without using the writer under test."""
194194
name = path.encode("utf-8")
195-
entry = struct.pack(">10L20sH", 0, 0, 0, 0, 0, 0, 0o100644, 0, 0, 0, b"a" * 20, min(len(name), 0xFFF)) + name
195+
entry = struct.pack(">10L20sH", 0, 0, 0, 0, 0, 0, mode, 0, 0, 0, b"a" * 20, min(len(name), 0xFFF)) + name
196196
entry += b"\0" * (8 - len(entry) % 8)
197197
data = b"DIRC" + struct.pack(">LL", 2, 1) + entry
198198
return data + sha1(data).digest()
@@ -340,6 +340,33 @@ def test_index_reader_and_writer_reject_unsafe_paths(self, path):
340340
with pytest.raises(ValueError):
341341
write_cache([entry], BytesIO())
342342

343+
@ddt.data(
344+
".gitmodules",
345+
".GITMODULES",
346+
".gitmodules.",
347+
".gitmodules ",
348+
".gi\u200ctmodules",
349+
"gitmod~1",
350+
"gitmod~4",
351+
"gi7eba~1",
352+
"gi7eba~9",
353+
"sub/.gitmodules",
354+
)
355+
def test_index_reader_and_writer_reject_gitmodules_symlinks(self, path):
356+
"""An entry that turns .gitmodules into a symbolic link is rejected, while the
357+
same name stays valid for a regular file. The spellings are those
358+
`git update-index --add --cacheinfo 120000,<sha>,<path>` refuses on git 2.52.0."""
359+
with pytest.raises(ValueError):
360+
read_cache(BytesIO(_raw_index(path, mode=0o120000)))
361+
with pytest.raises(ValueError):
362+
write_cache([IndexEntry((0o120000, b"a" * 20, 0, path))], BytesIO())
363+
364+
assert next(iter(read_cache(BytesIO(_raw_index(path)))[1])) == (path, 0)
365+
stream = BytesIO()
366+
write_cache([IndexEntry((0o100644, b"a" * 20, 0, path))], stream)
367+
stream.seek(0)
368+
assert next(iter(read_cache(stream)[1])) == (path, 0)
369+
343370
def test_valid_unusual_index_names_round_trip(self):
344371
names = ["a b", "a\nb", "a\tb", "name:value", "dir/.gitignore", "café"]
345372
if os.name != "nt":

‎test/test_tree.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,28 @@ def test_tree_names_are_checked_at_construction_and_serialization(self, name):
138138
with pytest.raises(ValueError):
139139
tree_to_stream([(b"a" * 20, 0o100644, name)], BytesIO().write)
140140

141+
@ddt.data(".gitmodules", ".GITMODULES", ".gitmodules ", ".gi\u200ctmodules", "gitmod~1", "gi7eba~1")
142+
def test_gitmodules_symlink_entries_are_rejected(self, name):
143+
"""A symbolic link named like the submodule configuration would make Git read
144+
it from outside the repository, so such an entry is refused in both
145+
directions. A regular file with the same name is the normal case."""
146+
symlink_mode = 0o120000
147+
cache = []
148+
with pytest.raises(ValueError):
149+
TreeModifier(cache).add(b"a" * 20, symlink_mode, name)
150+
assert not cache
151+
with pytest.raises(ValueError):
152+
tree_to_stream([(b"a" * 20, symlink_mode, name)], BytesIO().write)
153+
raw = b"120000 " + name.encode() + b"\0" + b"a" * 20
154+
with pytest.raises(ValueError):
155+
tree_entries_from_data(raw)
156+
157+
TreeModifier(cache).add(b"a" * 20, 0o100644, name)
158+
assert cache == [(b"a" * 20, 0o100644, name)]
159+
data = BytesIO()
160+
tree_to_stream(cache, data.write)
161+
assert tree_entries_from_data(data.getvalue()) == cache
162+
141163
def test_traverse(self):
142164
root = self.rorepo.tree("0.1.6")
143165
num_recursive = 0

0 commit comments

Comments
 (0)