Skip to content

Commit 6bf77c0

Browse files
Byroncodex
andcommitted
fix: validate size lookups and preserve binary SHAs (#2254)
<!-- agent --> An object constructed with a SHA for another Git object type could silently expose that object's size. Check the type metadata already returned by `odb.info()` when loading an uncached `Object.size`, and raise `ValueError` naming the SHA, actual type, and expected type. Limit validation to this metadata lookup. Rejecting an unread stream can leave payload bytes in the persistent `git cat-file --batch` response while an exception retains its traceback, corrupting subsequent reads. Stream consumers keep their existing behavior, with no type assertions or added draining. Constructors remain lazy, and object-data parsing still relies on callers to supply a SHA of the appropriate type. Preserve binary SHAs in `IndexFile.new()` instead of converting them to their Python string representation. Accept binary and hexadecimal bytes alongside hex strings and `Tree` objects. Represent submodule diff entries with `IndexObject`, retaining their path, mode, and access to the submodule's commit data without labeling those commits as blobs. Extend existing object, index, and submodule diff tests to cover binary SHA inputs, metadata mismatches, and raw streaming through wrappers of another object type. Remove the obsolete `IndexFile.new()` assignment diagnostic from the `basedpyright` baseline. Validation: 67 affected tests and Ruff lint/format checks passed. Subsequent reads were also verified with active parser exceptions using both `GitCmdObjectDB` and `GitDB`. Assisted-by: GPT 6.0 Co-authored-by: GPT 6.0 <codex@openai.com>
1 parent 71b9545 commit 6bf77c0

7 files changed

Lines changed: 49 additions & 21 deletions

File tree

‎.basedpyright/baseline.json‎

Lines changed: 0 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -77,14 +77,6 @@
7777
"lineCount": 1
7878
}
7979
},
80-
{
81-
"code": "reportAssignmentType",
82-
"range": {
83-
"startColumn": 38,
84-
"endColumn": 76,
85-
"lineCount": 1
86-
}
87-
},
8880
{
8981
"code": "reportArgumentType",
9082
"range": {

‎git/diff.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
from git.cmd import Git, handle_process_output
1313
from git.compat import defenc
14+
from git.objects.base import IndexObject
1415
from git.objects.blob import Blob
1516
from git.objects.util import mode_str_to_int
1617
from git.util import finalize_process, hex_to_bin
@@ -35,7 +36,6 @@
3536
if TYPE_CHECKING:
3637
from subprocess import Popen
3738

38-
from git.objects.base import IndexObject
3939
from git.objects.commit import Commit
4040
from git.objects.tree import Tree
4141
from git.repo.base import Repo
@@ -378,6 +378,10 @@ class Diff:
378378
Diffs keep information about the changed blob objects, the file mode, renames,
379379
deletions and new files.
380380
381+
For submodule changes, ``a_blob`` and ``b_blob`` are
382+
:class:`~git.objects.base.IndexObject` instances whose SHAs refer to commits in
383+
the submodule repository.
384+
381385
There are a few cases where ``None`` has to be expected as member variable value:
382386
383387
New File::
@@ -481,17 +485,22 @@ def __init__(
481485
repo = submodule.module()
482486
break
483487

488+
# Gitlinks reference commits; generic index objects preserve their path and mode.
484489
self.a_blob: Union["IndexObject", None]
485490
if a_blob_id is None or a_blob_id == self.NULL_HEX_SHA:
486491
self.a_blob = None
487492
else:
488-
self.a_blob = Blob(repo, hex_to_bin(a_blob_id), mode=self.a_mode, path=self.a_path)
493+
self.a_blob = (IndexObject if self.a_mode == 0o160000 else Blob)(
494+
repo, hex_to_bin(a_blob_id), mode=self.a_mode, path=self.a_path
495+
)
489496

490497
self.b_blob: Union["IndexObject", None]
491498
if b_blob_id is None or b_blob_id == self.NULL_HEX_SHA:
492499
self.b_blob = None
493500
else:
494-
self.b_blob = Blob(repo, hex_to_bin(b_blob_id), mode=self.b_mode, path=self.b_path)
501+
self.b_blob = (IndexObject if self.b_mode == 0o160000 else Blob)(
502+
repo, hex_to_bin(b_blob_id), mode=self.b_mode, path=self.b_path
503+
)
495504

496505
self.new_file: bool = new_file
497506
self.deleted_file: bool = deleted_file

‎git/index/base.py‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ def merge_tree(
310310
return self
311311

312312
@classmethod
313-
def new(cls, repo: "Repo", *tree_sha: Union[str, Tree]) -> "IndexFile":
313+
def new(cls, repo: "Repo", *tree_sha: Union[str, bytes, Tree]) -> "IndexFile":
314314
"""Merge the given treeish revisions into a new index which is returned.
315315
316316
This method behaves like ``git-read-tree --aggressive`` when doing the merge.
@@ -326,7 +326,9 @@ def new(cls, repo: "Repo", *tree_sha: Union[str, Tree]) -> "IndexFile":
326326
If you intend to write such a merged Index, supply an alternate
327327
``file_path`` to its :meth:`write` method.
328328
"""
329-
tree_sha_bytes: List[bytes] = [to_bin_sha(str(t)) for t in tree_sha]
329+
tree_sha_bytes: List[bytes] = [
330+
to_bin_sha(t if isinstance(t, bytes) else str(t).encode("ascii")) for t in tree_sha
331+
]
330332
base_entries = aggressive_tree_merge(repo.odb, tree_sha_bytes)
331333

332334
inst = cls(repo)

‎git/objects/base.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99

1010
import gitdb.typ as dbtyp
1111

12+
from git.compat import force_text
1213
from git.exc import WorkTreeRepositoryUnsupported
1314
from git.util import LazyMixin, bin_to_hex, join_path_native, stream_copy
1415

@@ -107,6 +108,10 @@ def __init__(self, repo: "Repo", binsha: bytes) -> None:
107108
108109
:param binsha:
109110
20 byte SHA1
111+
112+
:note:
113+
Object data is loaded lazily. Loading uncached :attr:`size` metadata
114+
raises :exc:`ValueError` if `binsha` refers to a different object type.
110115
"""
111116
super().__init__()
112117
self.repo = repo
@@ -155,6 +160,9 @@ def _set_cache_(self, attr: str) -> None:
155160
"""Retrieve object information."""
156161
if attr == "size":
157162
oinfo = self.repo.odb.info(self.binsha)
163+
typename = force_text(oinfo.type, "ascii")
164+
if self.type is not None and typename != self.type:
165+
raise ValueError("Object %s is a %s, not a %s" % (self.hexsha, typename, self.type))
158166
self.size = oinfo.size # type: int
159167
else:
160168
super()._set_cache_(attr)

‎test/test_base.py‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,15 @@
44
# 3-Clause BSD License: https://opensource.org/license/bsd-3-clause/
55

66
import gc
7+
from io import BytesIO
78
import os
89
import os.path as osp
910
import sys
1011
import tempfile
1112
from unittest import skipIf
13+
from unittest.mock import patch
14+
15+
from gitdb import OInfo
1216

1317
from git import Repo
1418
from git.objects import Blob, Commit, TagObject, Tree
@@ -76,6 +80,20 @@ def test_base_object(self):
7680
# Remove the file this way, instead of with a context manager or "finally",
7781
# so it is only removed on success, and we can inspect the file on failure.
7882
os.remove(tmpfile.name)
83+
84+
for stored_type in (typename, typename.encode("ascii")):
85+
with patch.object(self.rorepo.odb, "info", return_value=OInfo(binsha, stored_type, item.size)):
86+
self.assertEqual(obj_type(self.rorepo, binsha).size, item.size)
87+
for wrong_type in types:
88+
if wrong_type is obj_type:
89+
continue
90+
invalid = wrong_type(self.rorepo, binsha)
91+
with self.assertRaisesRegex(ValueError, f"{hexsha}.*{typename}.*{wrong_type.type}"):
92+
invalid.size
93+
self.assertEqual(invalid.data_stream.read(), data)
94+
ostream = BytesIO()
95+
invalid.stream_data(ostream)
96+
self.assertEqual(ostream.getvalue(), data)
7997
# END for each object type to create
8098

8199
# Each has a unique sha.

‎test/test_diff.py‎

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ def test_diff_submodule(self):
348348
with open(self.submodule_dir + "/subfile", "w") as sub_subfile:
349349
sub_subfile.write("")
350350
sub.index.add(["subfile"])
351-
sub.index.commit("first commit")
351+
first_commit = sub.index.commit("first commit")
352352

353353
# Init a temp git repo that will incorporate the submodule.
354354
repo = Repo.init(self.repo_dir)
@@ -364,7 +364,7 @@ def test_diff_submodule(self):
364364
with open(self.repo_dir + "/sub/subfile", "w") as foo_sub_subfile:
365365
foo_sub_subfile.write("blub")
366366
submodule.module().index.add(["subfile"])
367-
submodule.module().index.commit("changed subfile")
367+
changed_commit = submodule.module().index.commit("changed subfile")
368368
submodule.binsha = submodule.module().head.commit.binsha
369369

370370
# Commit submodule updates in parent repo.
@@ -373,11 +373,10 @@ def test_diff_submodule(self):
373373
repo.create_tag("2")
374374

375375
diff = repo.commit("1").diff(repo.commit("2"))[0]
376-
# If diff is unable to find the commit hashes (looks in wrong repo) the
377-
# *_blob.size property will be a string containing exception text, an int
378-
# indicates success.
379-
self.assertIsInstance(diff.a_blob.size, int)
380-
self.assertIsInstance(diff.b_blob.size, int)
376+
# Gitlinks refer to commits in the submodule's object database.
377+
for item, commit in ((diff.a_blob, first_commit), (diff.b_blob, changed_commit)):
378+
self.assertEqual(item.size, commit.size)
379+
self.assertEqual(item.data_stream.read(), commit.data_stream.read())
381380

382381
def test_diff_rejects_unsafe_output_options(self):
383382
commit = self.rorepo.head.commit

‎test/test_index.py‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1030,7 +1030,7 @@ def test_index_new(self):
10301030
H = self.rorepo.tree("25dca42bac17d511b7e2ebdd9d1d679e7626db5f")
10311031
M = self.rorepo.tree("e746f96bcc29238b79118123028ca170adc4ff0f")
10321032

1033-
for args in ((B,), (B, H), (B, H, M)):
1033+
for args in ((B.binsha,), (B.hexsha, H), (B, H.binsha, M.hexsha.encode("ascii"))):
10341034
index = IndexFile.new(self.rorepo, *args)
10351035
assert isinstance(index, IndexFile)
10361036
# END for each arg tuple

0 commit comments

Comments
 (0)