Skip to content

Commit 9f56080

Browse files
committed
review
- speedup additional .gitmodules check
1 parent 999c765 commit 9f56080

4 files changed

Lines changed: 236 additions & 51 deletions

File tree

‎git/index/base.py‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -728,8 +728,10 @@ def _preprocess_add_items(
728728
else:
729729
raise TypeError("Invalid Type: %r" % item)
730730
# END for each item
731+
# Source paths must be safe to read, but their recorded names may be rewritten.
732+
# Apply mode-dependent restrictions to the final entries in add().
731733
for entry in entries:
732-
_validate_repo_path(entry.path, entry.mode)
734+
_validate_repo_path(entry.path)
733735
return paths, entries
734736

735737
def _store_path(self, filepath: PathLike, fprogress: Callable) -> BaseIndexEntry:

‎git/util.py‎

Lines changed: 38 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -29,63 +29,62 @@
2929
if sys.platform == "win32":
3030
__all__.append("to_native_path_windows")
3131

32-
from abc import abstractmethod
3332
import contextlib
34-
from functools import wraps
3533
import getpass
3634
import logging
3735
import ntpath
3836
import os
3937
import os.path as osp
40-
from pathlib import Path
4138
import platform
4239
import re
4340
import shutil
4441
import stat
4542
import subprocess
4643
import time
47-
from urllib.parse import urlsplit, urlunsplit
4844
import warnings
49-
50-
# NOTE: Unused imports can be improved now that CI testing has fully resumed. Some of
51-
# these be used indirectly through other GitPython modules, which avoids having to write
52-
# gitdb all the time in their imports. They are not in __all__, at least currently,
53-
# because they could be removed or changed at any time, and so should not be considered
54-
# conceptually public to code outside GitPython. Linters of course do not like it.
55-
from gitdb.util import (
56-
LazyMixin, # noqa: F401
57-
LockedFD, # noqa: F401
58-
bin_to_hex, # noqa: F401
59-
file_contents_ro, # noqa: F401
60-
file_contents_ro_filepath, # noqa: F401
61-
hex_to_bin, # noqa: F401
62-
make_sha,
63-
to_bin_sha, # noqa: F401
64-
to_hex_sha, # noqa: F401
65-
)
45+
from abc import abstractmethod
46+
from functools import wraps
47+
from pathlib import Path
6648

6749
# typing ---------------------------------------------------------
68-
6950
from typing import (
51+
IO,
52+
TYPE_CHECKING,
7053
Any,
7154
AnyStr,
7255
Callable,
7356
Dict,
7457
Generator,
75-
IO,
7658
Iterator,
7759
List,
7860
Optional,
7961
Pattern,
8062
Sequence,
8163
Tuple,
82-
TYPE_CHECKING,
8364
Type,
8465
TypeVar,
8566
Union,
8667
cast,
8768
overload,
8869
)
70+
from urllib.parse import urlsplit, urlunsplit
71+
72+
# NOTE: Unused imports can be improved now that CI testing has fully resumed. Some of
73+
# these be used indirectly through other GitPython modules, which avoids having to write
74+
# gitdb all the time in their imports. They are not in __all__, at least currently,
75+
# because they could be removed or changed at any time, and so should not be considered
76+
# conceptually public to code outside GitPython. Linters of course do not like it.
77+
from gitdb.util import (
78+
LazyMixin, # noqa: F401
79+
LockedFD, # noqa: F401
80+
bin_to_hex, # noqa: F401
81+
file_contents_ro, # noqa: F401
82+
file_contents_ro_filepath, # noqa: F401
83+
hex_to_bin, # noqa: F401
84+
make_sha,
85+
to_bin_sha, # noqa: F401
86+
to_hex_sha, # noqa: F401
87+
)
8988

9089
if TYPE_CHECKING:
9190
from git.cmd import Git
@@ -94,9 +93,9 @@
9493
from git.repo.base import Repo
9594

9695
from git.types import (
96+
HSH_TD,
9797
Files_TD,
9898
Has_id_attribute,
99-
HSH_TD,
10099
Literal,
101100
PathLike,
102101
Protocol,
@@ -385,11 +384,13 @@ def _to_relative_path(root: PathLike, path: PathLike) -> str:
385384
"", "", "\u200c\u200d\u200e\u200f\u202a\u202b\u202c\u202d\u202e\u206a\u206b\u206c\u206d\u206e\u206f\ufeff"
386385
)
387386

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)]
387+
# Match Git's is_ntfs_dotgitmodules in path.c on a lowercased, trimmed name.
388+
# Besides gitmod~1..4, fallback aliases have exactly eight ASCII characters:
389+
# a shrinking prefix of "gi7eba", "~", and digits with no leading zero.
390+
# Explicit digit counts avoid accepting shorter/longer names or Unicode digits.
391+
_NTFS_DOTGITMODULES_SHORT_NAME = re.compile(
392+
r"(?:gitmod~[1-4]|gi7eba~[1-9]|gi7eb~[1-9][0-9]|gi7e~[1-9][0-9]{2}|"
393+
r"gi7~[1-9][0-9]{3}|gi~[1-9][0-9]{4}|g~[1-9][0-9]{5}|~[1-9][0-9]{6})"
393394
)
394395

395396

@@ -419,11 +420,13 @@ def _validate_repo_path(path: PathLike, mode: Union[int, None] = None) -> None:
419420
hfs_name = part.translate(_HFS_IGNORABLES).lower()
420421
if ntfs_name in (".git", "git~1") or hfs_name == ".git":
421422
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)
423+
if mode is not None and stat.S_ISLNK(mode):
424+
if (
425+
ntfs_name == ".gitmodules"
426+
or hfs_name == ".gitmodules"
427+
or _NTFS_DOTGITMODULES_SHORT_NAME.fullmatch(ntfs_name) is not None
428+
):
429+
raise ValueError("Symbolic link aliases the submodule configuration: %r" % name)
427430

428431

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

‎test/test_index.py‎

Lines changed: 109 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,21 @@ def test_index_reader_and_writer_reject_unsafe_paths(self, path):
350350
"gitmod~4",
351351
"gi7eba~1",
352352
"gi7eba~9",
353+
"GI7EB~10",
354+
"GI7EB~99",
355+
"GI7E~100",
356+
"GI7E~999",
357+
"GI7~1000",
358+
"GI7~9999",
359+
"GI~10000",
360+
"GI~99999",
361+
"G~100000",
362+
"G~999999",
363+
"~1000000",
364+
"~9999999",
365+
"GI7EB~10. ",
366+
"GI7E~100:$DATA",
367+
"sub/~1000000",
353368
"sub/.gitmodules",
354369
)
355370
def test_index_reader_and_writer_reject_gitmodules_symlinks(self, path):
@@ -367,6 +382,26 @@ def test_index_reader_and_writer_reject_gitmodules_symlinks(self, path):
367382
stream.seek(0)
368383
assert next(iter(read_cache(stream)[1])) == (path, 0)
369384

385+
@ddt.data("GI7EB~10", "GI7E~100", "GI7~1000", "GI~10000", "G~100000", "~1000000", "~9999999")
386+
@with_rw_directory
387+
def test_index_add_and_write_tree_reject_gitmodules_fallback_symlinks(self, rw_dir, path):
388+
with Repo.init(rw_dir) as repo:
389+
binsha = repo.odb.store(IStream("blob", 6, BytesIO(b"target"))).binsha
390+
index = repo.index
391+
for item in (Blob(repo, binsha, 0o120000, path), BaseIndexEntry((0o120000, binsha, 0, path))):
392+
with pytest.raises(ValueError, match="submodule configuration"):
393+
index.add([item], write=False)
394+
assert not index.entries
395+
396+
index.entries[(path, 0)] = IndexEntry((0o120000, binsha, 0, path))
397+
with pytest.raises(ValueError, match="submodule configuration"):
398+
index.write_tree()
399+
assert not Path(index.path).exists()
400+
401+
index.add([BaseIndexEntry((0o100644, binsha, 0, path))])
402+
assert repo.index.entries[(path, 0)].mode == 0o100644
403+
assert index.write_tree()[path].mode == 0o100644
404+
370405
def test_valid_unusual_index_names_round_trip(self):
371406
names = ["a b", "a\nb", "a\tb", "name:value", "dir/.gitignore", "café"]
372407
if os.name != "nt":
@@ -408,20 +443,26 @@ def _cmp_tree_index(self, tree, index):
408443

409444
@with_rw_repo("0.1.6")
410445
def test_index_lock_handling(self, rw_repo):
411-
def add_bad_blob():
412-
rw_repo.index.add([Blob(rw_repo, b"f" * 20, "bad-permissions", "foo")])
413-
414-
try:
415-
## First, fail on purpose adding into index.
416-
add_bad_blob()
417-
except Exception as ex:
418-
assert "required argument is not an integer" in str(ex)
419-
420-
## The second time should not fail due to stray lock file.
421-
try:
422-
add_bad_blob()
423-
except Exception as ex:
424-
assert "index.lock' could not be obtained" not in str(ex)
446+
index = rw_repo.index
447+
index_path = Path(index.path)
448+
lock_path = Path(str(index_path) + ".lock")
449+
before = index_path.read_bytes()
450+
451+
def fail_serialize(stream, ignore_extension_data):
452+
assert lock_path.exists()
453+
stream.write(b"partial index")
454+
raise OSError("simulated index write failure")
455+
456+
with mock.patch.object(IndexFile, "_serialize", side_effect=fail_serialize):
457+
for _ in range(2):
458+
with pytest.raises(OSError, match="simulated index write failure"):
459+
index.write()
460+
assert not lock_path.exists()
461+
assert index_path.read_bytes() == before
462+
463+
index.add([Blob(rw_repo, b"f" * 20, 0o100644, "foo")])
464+
assert not lock_path.exists()
465+
assert rw_repo.index.entries[("foo", 0)].mode == 0o100644
425466

426467
@with_rw_repo("0.1.6")
427468
def test_read_tree_methods_reject_index_output(self, rw_repo):
@@ -1248,6 +1289,60 @@ def test_staging_rejects_unsafe_object_paths_even_without_writing(self, rw_dir,
12481289
index.add([item], write=False, **kwargs)
12491290
assert not index.entries
12501291

1292+
@ddt.data(*product(("path", "blob", "entry", "stored-blob", "stored-entry"), (False, True), (False, True)))
1293+
@ddt.unpack
1294+
@with_rw_directory
1295+
def test_staging_gitmodules_symlink_check_uses_rewritten_path(self, rw_dir, kind, unsafe_destination, write):
1296+
with Repo.init(rw_dir) as repo:
1297+
source, destination = ("safe-link", ".gitmodules") if unsafe_destination else (".gitmodules", "safe-link")
1298+
binsha = Blob.NULL_BIN_SHA
1299+
if kind.startswith("stored-"):
1300+
binsha = repo.odb.store(IStream("blob", 6, BytesIO(b"target"))).binsha
1301+
else:
1302+
try:
1303+
(Path(rw_dir) / source).symlink_to("target")
1304+
except OSError:
1305+
pytest.skip("Symlinks unavailable")
1306+
if kind == "path":
1307+
item = source
1308+
elif kind.endswith("blob"):
1309+
item = Blob(repo, binsha, 0o120000, source)
1310+
else:
1311+
item = BaseIndexEntry((0o120000, binsha, 0, source))
1312+
index = repo.index
1313+
rewriter = mock.Mock(return_value=destination)
1314+
if unsafe_destination:
1315+
with pytest.raises(ValueError, match="submodule configuration"):
1316+
index.add([item], path_rewriter=rewriter, write=write)
1317+
assert not index.entries
1318+
assert not Path(index.path).exists()
1319+
else:
1320+
added = index.add([item], path_rewriter=rewriter, write=write)
1321+
assert [(entry.path, entry.mode) for entry in added] == [(destination, 0o120000)]
1322+
assert set(index.entries) == {(destination, 0)}
1323+
assert index.write_tree()[destination].mode == 0o120000
1324+
if write:
1325+
assert repo.index.entries[(destination, 0)].mode == 0o120000
1326+
rewriter.assert_called_once()
1327+
assert rewriter.call_args[0][0].path == source
1328+
1329+
@ddt.data(*product(("blob", "entry"), ("../outside", ".git/config")))
1330+
@ddt.unpack
1331+
@with_rw_directory
1332+
def test_staging_rewriter_cannot_sanitize_unsafe_source_paths(self, rw_dir, kind, path):
1333+
with Repo.init(rw_dir) as repo:
1334+
item = (
1335+
Blob(repo, b"a" * 20, 0o120000, path)
1336+
if kind == "blob"
1337+
else BaseIndexEntry((0o120000, b"a" * 20, 0, path))
1338+
)
1339+
index = repo.index
1340+
rewriter = mock.Mock(return_value="safe-link")
1341+
with pytest.raises(ValueError):
1342+
index.add([item], path_rewriter=rewriter, write=False)
1343+
rewriter.assert_not_called()
1344+
assert not index.entries
1345+
12511346
@with_rw_directory
12521347
def test_staging_root_preserves_symlinks_and_skips_git_metadata(self, rw_dir):
12531348
tmp_path = Path(rw_dir)

‎test/test_tree.py‎

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,38 @@ 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")
141+
@ddt.data(
142+
".gitmodules",
143+
".GITMODULES",
144+
".gitmodules ",
145+
".gi\u200ctmodules",
146+
"gitmod~1",
147+
"gitmod~2",
148+
"gitmod~3",
149+
"GITMOD~4",
150+
"gi7eba~1",
151+
"GI7EBA~9",
152+
"GI7EB~10",
153+
"GI7EB~11",
154+
"GI7EB~99",
155+
"GI7E~100",
156+
"GI7E~101",
157+
"GI7E~999",
158+
"GI7~1000",
159+
"GI7~9999",
160+
"GI~10000",
161+
"GI~99999",
162+
"G~100000",
163+
"G~999999",
164+
"~1000000",
165+
"~9999999",
166+
"Gi7Eb~42",
167+
"gi7e~120",
168+
"GITMOD~4 . ",
169+
"GI7EB~10. ",
170+
"GI7E~100:$DATA",
171+
"~1000000 . :$DATA",
172+
)
142173
def test_gitmodules_symlink_entries_are_rejected(self, name):
143174
"""A symbolic link named like the submodule configuration would make Git read
144175
it from outside the repository, so such an entry is refused in both
@@ -160,6 +191,60 @@ def test_gitmodules_symlink_entries_are_rejected(self, name):
160191
tree_to_stream(cache, data.write)
161192
assert tree_entries_from_data(data.getvalue()) == cache
162193

194+
@ddt.data(
195+
"gitmod~0",
196+
"gitmod~5",
197+
"gitmod~10",
198+
"GI7EBA~",
199+
"GI7EBA~0",
200+
"GI7EBA~~1",
201+
"GI7EBA~X",
202+
"GI7EBA~10",
203+
"Gx7EBA~1",
204+
"GI7EBX~1",
205+
"GI7EB~1",
206+
"GI7EB~01",
207+
"GI7EB~1X",
208+
"GI7EB~100",
209+
"GI7E~10",
210+
"GI7E~010",
211+
"GI7E~1000",
212+
"GI7~100",
213+
"GI7~0100",
214+
"GI7~10000",
215+
"GI~1000",
216+
"GI~01000",
217+
"GI~100000",
218+
"G~10000",
219+
"G~010000",
220+
"G~1000000",
221+
"~100000",
222+
"~0100000",
223+
"~10000000",
224+
"GI7EBA~\u0661",
225+
"GI7EB~1\uff10",
226+
"GI7EB~10x",
227+
"GI7EB~10.x",
228+
" GI7EB~10",
229+
"GI7EB~10\n",
230+
"GI7EB~10\t",
231+
"GI7EB~10x:$DATA",
232+
".gitmodules x",
233+
".gitmodules .x",
234+
".gitmodules,:$DATA",
235+
)
236+
def test_gitmodules_short_name_near_misses_round_trip(self, name):
237+
"""Only exact aliases are forbidden, and only for symbolic links."""
238+
for mode in (0o100644, 0o120000):
239+
cache = []
240+
TreeModifier(cache).add(b"a" * 20, mode, name)
241+
assert cache == [(b"a" * 20, mode, name)]
242+
data = BytesIO()
243+
tree_to_stream(cache, data.write)
244+
raw = ("%o " % mode).encode() + name.encode() + b"\0" + b"a" * 20
245+
assert data.getvalue() == raw
246+
assert tree_entries_from_data(raw) == cache
247+
163248
def test_traverse(self):
164249
root = self.rorepo.tree("0.1.6")
165250
num_recursive = 0

0 commit comments

Comments
 (0)