Skip to content

Commit 7f503ff

Browse files
committed
Fix stale libgit2 errors clobbering Python backend exceptions
When a custom ODB or refdb callback raised an arbitrary Python exception, git_error_for_exc() returned GIT_EUSER and left the real exception pending. The entry points then called Error_set/err/str/oid, which read git_error_last() and overwrote the pending exception with whatever stale text happened to be in libgit2's buffer (commonly an OSError about .git/shallow). Preserve the pending Python exception when err == GIT_EUSER in the three Error_set* helpers, so the callback's original exception propagates. This does not affect the KeyError -> GIT_ENOTFOUND or ValueError -> GIT_EAMBIGUOUS mappings, which return different error codes. Add regression tests for refdb exists/lookup and ODB read/read_prefix/ read_header/exists/exists_prefix callbacks, all expecting the original RuntimeError to propagate. Assisted-by: Kimi Code
1 parent 490bd88 commit 7f503ff

4 files changed

Lines changed: 102 additions & 13 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@
66
built-in exception (`ValueError`/`KeyError`) for backward compatibility
77
[#830](https://github.com/libgit2/pygit2/issues/830).
88

9+
- Fix custom ODB and refdb backend callbacks overwriting a pending Python
10+
exception (e.g. `RuntimeError`) with a stale libgit2 error message.
11+
912
- Fix `DiffDelta.is_binary` and `DiffDelta.flags` returning stale values for
1013
deltas obtained from `Diff.deltas`; flags are now loaded lazily
1114
[#962](https://github.com/libgit2/pygit2/issues/962)

src/error.c

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ Error_set(int err)
104104
{
105105
assert(err < 0);
106106

107+
/* GIT_EUSER means a Python callback raised an exception. Preserve that
108+
* exception instead of overwriting it with a stale libgit2 error message. */
109+
if (err == GIT_EUSER && PyErr_Occurred())
110+
return NULL;
111+
107112
return Error_set_exc(Error_type(err));
108113
}
109114

@@ -122,6 +127,10 @@ Error_set_exc(PyObject* exception)
122127
PyObject *
123128
Error_set_str(int err, const char *str)
124129
{
130+
/* GIT_EUSER means a Python callback raised an exception. Preserve it. */
131+
if (err == GIT_EUSER && PyErr_Occurred())
132+
return NULL;
133+
125134
if (err == GIT_ENOTFOUND) {
126135
/* NotFoundError inherits from KeyError; the argument is the missing key. */
127136
PyErr_SetString(NotFoundError, str);
@@ -138,6 +147,10 @@ Error_set_str(int err, const char *str)
138147
PyObject *
139148
Error_set_oid(int err, const git_oid *oid, size_t len)
140149
{
150+
/* GIT_EUSER means a Python callback raised an exception. Preserve it. */
151+
if (err == GIT_EUSER && PyErr_Occurred())
152+
return NULL;
153+
141154
char hex[GIT_OID_HEXSZ + 1];
142155

143156
git_oid_fmt(hex, oid);

test/test_odb_backend.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,29 @@ def __iter__(self) -> Iterator[Oid]:
9595
return iter(self.source)
9696

9797

98+
class RaisingOdbBackend(pygit2.OdbBackend):
99+
"""A backend whose callbacks always raise a configurable exception."""
100+
101+
def __init__(self, exc: Exception) -> None:
102+
super().__init__()
103+
self.exc = exc
104+
105+
def read_cb(self, oid: Oid | str) -> tuple[int, bytes]:
106+
raise self.exc
107+
108+
def read_prefix_cb(self, oid: Oid | str) -> tuple[int, bytes, Oid]:
109+
raise self.exc
110+
111+
def read_header_cb(self, oid: Oid | str) -> tuple[int, int]:
112+
raise self.exc
113+
114+
def exists_cb(self, oid: Oid | str) -> bool:
115+
raise self.exc
116+
117+
def exists_prefix_cb(self, oid: Oid | str) -> Oid:
118+
raise self.exc
119+
120+
98121
#
99122
# Test a custom object backend alone (without adding it to an ODB)
100123
# This doesn't make much sense, but it's possible.
@@ -141,6 +164,44 @@ def test_exists_prefix(proxy: ProxyBackend) -> None:
141164
assert BLOB_HEX == proxy.exists_prefix(a_hex_prefix)
142165

143166

167+
@pytest.fixture
168+
def raising_backend() -> Generator[RaisingOdbBackend, None, None]:
169+
yield RaisingOdbBackend(RuntimeError('boom'))
170+
171+
172+
def test_read_cb_raises_runtime_error(raising_backend: RaisingOdbBackend) -> None:
173+
# Regression test: a RuntimeError in read_cb must propagate as RuntimeError,
174+
# not be overwritten by a stale libgit2 error message.
175+
with pytest.raises(RuntimeError, match='boom'):
176+
pygit2.OdbBackend.read(raising_backend, BLOB_OID)
177+
178+
179+
def test_read_prefix_cb_raises_runtime_error(
180+
raising_backend: RaisingOdbBackend,
181+
) -> None:
182+
with pytest.raises(RuntimeError, match='boom'):
183+
pygit2.OdbBackend.read_prefix(raising_backend, BLOB_HEX[:4])
184+
185+
186+
def test_read_header_cb_raises_runtime_error(
187+
raising_backend: RaisingOdbBackend,
188+
) -> None:
189+
with pytest.raises(RuntimeError, match='boom'):
190+
pygit2.OdbBackend.read_header(raising_backend, BLOB_OID)
191+
192+
193+
def test_exists_cb_raises_runtime_error(raising_backend: RaisingOdbBackend) -> None:
194+
with pytest.raises(RuntimeError, match='boom'):
195+
pygit2.OdbBackend.exists(raising_backend, BLOB_OID)
196+
197+
198+
def test_exists_prefix_cb_raises_runtime_error(
199+
raising_backend: RaisingOdbBackend,
200+
) -> None:
201+
with pytest.raises(RuntimeError, match='boom'):
202+
pygit2.OdbBackend.exists_prefix(raising_backend, BLOB_HEX[:4])
203+
204+
144205
#
145206
# Test a custom object backend, through a Repository.
146207
#

test/test_refdb_backend.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -140,24 +140,36 @@ def test_exists(repo: Repository) -> None:
140140

141141

142142
class RaisingRefdbBackend(ProxyRefdbBackend):
143-
"""A backend whose exists callback always raises."""
143+
"""A backend whose callbacks always raise RuntimeError."""
144+
145+
def __init__(self, source: pygit2.RefdbBackend, exc: Exception) -> None:
146+
super().__init__(source)
147+
self.exc = exc
144148

145149
def exists(self, ref: str) -> bool:
146-
raise RuntimeError('boom')
147-
148-
149-
def test_exists_callback_raises(testrepo: Repository) -> None:
150-
# Regression test (issue #1476): when the exists callback raises, the C
151-
# wrapper must not crash on a NULL result, and must propagate the error.
152-
backend = RaisingRefdbBackend(pygit2.RefdbFsBackend(testrepo))
153-
# Call the unbound C method so the call goes through the C wrapper
154-
# (pygit2_refdb_backend_exists), not directly to the Python override.
155-
# The error propagates as GitError, or as OSError/ValueError when a stale
156-
# libgit2 error (with a matching class) is left over from an earlier call.
157-
with pytest.raises((pygit2.GitError, OSError)):
150+
raise self.exc
151+
152+
def lookup(self, ref: str) -> Reference:
153+
raise self.exc
154+
155+
156+
def test_exists_callback_raises_runtime_error(testrepo: Repository) -> None:
157+
# Regression test: when the exists callback raises RuntimeError, the C
158+
# wrapper must propagate the original Python exception, not overwrite it
159+
# with a stale libgit2 error message.
160+
backend = RaisingRefdbBackend(pygit2.RefdbFsBackend(testrepo), RuntimeError('boom'))
161+
with pytest.raises(RuntimeError, match='boom'):
158162
pygit2.RefdbBackend.exists(backend, 'refs/heads/master')
159163

160164

165+
def test_lookup_callback_raises_runtime_error(testrepo: Repository) -> None:
166+
# Regression test: when the lookup callback raises RuntimeError, the C
167+
# wrapper must propagate the original Python exception.
168+
backend = RaisingRefdbBackend(pygit2.RefdbFsBackend(testrepo), RuntimeError('boom'))
169+
with pytest.raises(RuntimeError, match='boom'):
170+
pygit2.RefdbBackend.lookup(backend, 'refs/heads/master')
171+
172+
161173
def test_lookup(repo: Repository) -> None:
162174
assert repo.backend.lookup('refs/heads/does-not-exist') is None
163175
assert repo.backend.lookup('refs/heads/master').name == 'refs/heads/master'

0 commit comments

Comments
 (0)