Skip to content

Commit 7ed5433

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 7ed5433

7 files changed

Lines changed: 41 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: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,10 @@ def __init__(self, repo: "Repo", binsha: bytes) -> None:
107107
108108
:param binsha:
109109
20 byte SHA1
110+
111+
:note:
112+
Object data is loaded lazily. Loading uncached :attr:`size` metadata
113+
raises :exc:`ValueError` if `binsha` refers to a different object type.
110114
"""
111115
super().__init__()
112116
self.repo = repo
@@ -155,6 +159,8 @@ def _set_cache_(self, attr: str) -> None:
155159
"""Retrieve object information."""
156160
if attr == "size":
157161
oinfo = self.repo.odb.info(self.binsha)
162+
if self.type is not None and oinfo.type != self.type.encode("ascii"):
163+
raise ValueError("Object %s is a %s, not a %s" % (self.hexsha, oinfo.type.decode("ascii"), self.type))
158164
self.size = oinfo.size # type: int
159165
else:
160166
super()._set_cache_(attr)

‎test/test_base.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
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
@@ -76,6 +77,17 @@ def test_base_object(self):
7677
# Remove the file this way, instead of with a context manager or "finally",
7778
# so it is only removed on success, and we can inspect the file on failure.
7879
os.remove(tmpfile.name)
80+
81+
for wrong_type in types:
82+
if wrong_type is obj_type:
83+
continue
84+
invalid = wrong_type(self.rorepo, binsha)
85+
with self.assertRaisesRegex(ValueError, f"{hexsha}.*{typename}.*{wrong_type.type}"):
86+
invalid.size
87+
self.assertEqual(invalid.data_stream.read(), data)
88+
ostream = BytesIO()
89+
invalid.stream_data(ostream)
90+
self.assertEqual(ostream.getvalue(), data)
7991
# END for each object type to create
8092

8193
# 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)