Skip to content

Commit 5878864

Browse files
committed
Fix ownership and lifetime bugs in refdb backend callbacks
The refdb backend callbacks that hand a reference to libgit2 all had the same bug class: they leaked the Python Reference returned or yielded by user code, and borrowed the inner git_reference pointer from it, so releasing the object without leaking would leave libgit2 with a dangling pointer. git_reference_dup() cannot be used to make a safe copy, because it requires the db field to be set, which libgit2 only does after the callback returns. Instead, detach the pointer when the callback returned a fresh object (refcount 1), and copy it with git_reference__alloc()/git_reference__alloc_symbolic() when it returned a shared object, e.g. one the backend caches and returns on every call; the copy loses the cached peel, which is only a hint. Fixed callbacks: - lookup() and rename() now handle a cached Reference instead of invalidating it on first use (the second lookup handed libgit2 a NULL pointer). - The iterator next() callback leaked every yielded Reference and borrowed its pointer, a double free waiting for the object to be collected; the glob filter also leaked every non-matching yield. The next_name() callback leaked as well, and now keeps the last yielded Reference alive so the borrowed name stays valid until the next call. - The iterator constructor crashed on a NULL glob, which is exactly what git_reference_iterator_new() passes; it had never been exercised through libgit2 before. Also fix Reference(name, oid, None) passing an uninitialized peel OID to git_reference__alloc(), so every backend-constructed direct reference carried a garbage peel into libgit2. Add test_lookup_cached_callback, test_iterator_callback and test_iterator_callback_no_leak, driving cached Reference objects through libgit2; each crashes or fails against the old code. Fixes #1475 Assisted-by: Kimi Code
1 parent 2efb706 commit 5878864

4 files changed

Lines changed: 176 additions & 34 deletions

File tree

CHANGELOG.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,10 @@
1919
bound to the `DIFF3` constant
2020
[#1483](https://github.com/libgit2/pygit2/pull/1483)
2121

22-
- Fix memory issues
22+
- Fix memory issues in refdb backend
2323
[#1471](https://github.com/libgit2/pygit2/issues/1471)
2424
[#1474](https://github.com/libgit2/pygit2/issues/1474)
25+
[#1475](https://github.com/libgit2/pygit2/issues/1475)
2526

2627
- Update wheels to libgit2 1.9.6 and OpenSSL 3.5.7
2728

src/refdb_backend.c

Lines changed: 84 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
#include "wildmatch.h"
3737
#include <git2/refdb.h>
3838
#include <git2/sys/refdb_backend.h>
39+
#include <git2/sys/refs.h>
3940
#include <git2/sys/errors.h>
4041

4142
extern PyTypeObject ReferenceType;
@@ -66,21 +67,82 @@ struct pygit2_refdb_backend
6667
struct pygit2_refdb_iterator {
6768
struct git_reference_iterator base;
6869
PyObject *iterator;
70+
PyObject *current; /* keeps the last Reference yielded by next_name alive */
6971
char *glob;
7072
};
7173

74+
// Copy a reference that does not belong to a refdb yet. git_reference_dup()
75+
// cannot be used here because it requires the db field to be set, which
76+
// libgit2 only does after the backend callback returns. The copy does not
77+
// preserve the cached peel of direct references, which is only a hint.
78+
static git_reference *
79+
copy_reference(const git_reference *ref)
80+
{
81+
if (git_reference_type(ref) == GIT_REF_SYMBOLIC) {
82+
return git_reference__alloc_symbolic(git_reference_name(ref),
83+
git_reference_symbolic_target(ref));
84+
}
85+
return git_reference__alloc(git_reference_name(ref),
86+
git_reference_target(ref), NULL);
87+
}
88+
89+
// Transfer the reference returned by a backend callback to libgit2, which
90+
// takes ownership and sets its db field itself once the callback returns.
91+
// If the callback returned a fresh object (refcount 1), detach the pointer
92+
// from the Python object before releasing it; if it returned a shared
93+
// object (e.g. one the backend caches and returns on every call), leave it
94+
// intact and give libgit2 a copy instead.
95+
// Consumes the reference to result; returns 0 or a libgit2 error code.
96+
static int
97+
transfer_reference(git_reference **out, Reference *result)
98+
{
99+
if (result->reference == NULL) {
100+
PyErr_SetString(PyExc_ValueError, "Reference object is no longer valid");
101+
Py_DECREF(result);
102+
return GIT_EUSER;
103+
}
104+
105+
if (Py_REFCNT((PyObject *)result) == 1) {
106+
*out = result->reference;
107+
result->reference = NULL;
108+
Py_DECREF(result);
109+
return 0;
110+
}
111+
112+
*out = copy_reference(result->reference);
113+
Py_DECREF(result);
114+
if (*out == NULL) {
115+
git_error_set(GIT_ERROR_NOMEMORY, "out of memory");
116+
return GIT_ERROR;
117+
}
118+
return 0;
119+
}
120+
121+
// Returns the next valid Reference from the Python iterator, or NULL when
122+
// the iterator is exhausted or an error occurred; check PyErr_Occurred()
123+
// to tell the two cases apart.
72124
static Reference *
73125
iterator_get_next(struct pygit2_refdb_iterator *iter)
74126
{
75127
Reference *ref;
76128
while ((ref = (Reference *)PyIter_Next(iter->iterator)) != NULL) {
77-
if (!iter->glob) {
78-
return ref;
129+
if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) {
130+
PyErr_SetString(PyExc_TypeError,
131+
"RefdbBackend iterator must yield References");
132+
Py_DECREF(ref);
133+
return NULL;
134+
}
135+
if (ref->reference == NULL) {
136+
PyErr_SetString(PyExc_ValueError,
137+
"Reference object is no longer valid");
138+
Py_DECREF(ref);
139+
return NULL;
79140
}
80-
const char *name = git_reference_name(ref->reference);
81-
if (wildmatch(iter->glob, name, 0) != WM_NOMATCH) {
141+
if (!iter->glob ||
142+
wildmatch(iter->glob, git_reference_name(ref->reference), 0) != WM_NOMATCH) {
82143
return ref;
83144
}
145+
Py_DECREF(ref);
84146
}
85147
return NULL;
86148
}
@@ -91,16 +153,12 @@ pygit2_refdb_iterator_next(git_reference **out, git_reference_iterator *_iter)
91153
struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter;
92154
Reference *ref = iterator_get_next(iter);
93155
if (ref == NULL) {
156+
if (PyErr_Occurred())
157+
return GIT_EUSER;
94158
*out = NULL;
95159
return GIT_ITEROVER;
96160
}
97-
if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) {
98-
PyErr_SetString(PyExc_TypeError,
99-
"RefdbBackend iterator must yield References");
100-
return GIT_EUSER;
101-
}
102-
*out = ref->reference;
103-
return 0;
161+
return transfer_reference(out, ref);
104162
}
105163

106164
static int
@@ -109,14 +167,15 @@ pygit2_refdb_iterator_next_name(const char **ref_name, git_reference_iterator *_
109167
struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter;
110168
Reference *ref = iterator_get_next(iter);
111169
if (ref == NULL) {
170+
if (PyErr_Occurred())
171+
return GIT_EUSER;
112172
*ref_name = NULL;
113173
return GIT_ITEROVER;
114174
}
115-
if (!PyObject_IsInstance((PyObject *)ref, (PyObject *)&ReferenceType)) {
116-
PyErr_SetString(PyExc_TypeError,
117-
"RefdbBackend iterator must yield References");
118-
return GIT_EUSER;
119-
}
175+
// The name is borrowed from the Reference; keep the object alive until
176+
// the next call or until the iterator is freed.
177+
Py_XDECREF(iter->current);
178+
iter->current = (PyObject *)ref;
120179
*ref_name = git_reference_name(ref->reference);
121180
return 0;
122181
}
@@ -125,6 +184,7 @@ static void
125184
pygit2_refdb_iterator_free(git_reference_iterator *_iter)
126185
{
127186
struct pygit2_refdb_iterator *iter = (struct pygit2_refdb_iterator *)_iter;
187+
Py_CLEAR(iter->current);
128188
Py_DECREF(iter->iterator);
129189
free(iter->glob);
130190
}
@@ -144,12 +204,17 @@ pygit2_refdb_backend_iterator(git_reference_iterator **iter,
144204
git_error_set(GIT_ERROR_NOMEMORY, "out of memory");
145205
return GIT_ERROR;
146206
}
207+
if (glob && (pyiter->glob = strdup(glob)) == NULL) {
208+
Py_DECREF(iterator);
209+
free(pyiter);
210+
git_error_set(GIT_ERROR_NOMEMORY, "out of memory");
211+
return GIT_ERROR;
212+
}
147213
*iter = (git_reference_iterator *)pyiter;
148214
pyiter->iterator = iterator;
149215
pyiter->base.next = pygit2_refdb_iterator_next;
150216
pyiter->base.next_name = pygit2_refdb_iterator_next_name;
151217
pyiter->base.free = pygit2_refdb_iterator_free;
152-
pyiter->glob = strdup(glob);
153218
return 0;
154219
}
155220

@@ -207,13 +272,7 @@ pygit2_refdb_backend_lookup(git_reference **out,
207272
return GIT_EUSER;
208273
}
209274

210-
// Ownership of the underlying git_reference is transferred to libgit2,
211-
// which sets its db field itself once the callback returns; detach it
212-
// from the Python object before releasing the object.
213-
*out = result->reference;
214-
result->reference = NULL;
215-
Py_DECREF(result);
216-
return 0;
275+
return transfer_reference(out, result);
217276
}
218277

219278
static int
@@ -321,13 +380,7 @@ pygit2_refdb_backend_rename(git_reference **out, git_refdb_backend *_be,
321380
return GIT_EUSER;
322381
}
323382

324-
// Ownership of the underlying git_reference is transferred to libgit2,
325-
// which sets its db field itself once the callback returns; detach it
326-
// from the Python object before releasing the object.
327-
*out = ref->reference;
328-
ref->reference = NULL;
329-
Py_DECREF(ref);
330-
return 0;
383+
return transfer_reference(out, ref);
331384
}
332385

333386
static int

src/reference.c

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,8 @@ Reference_init(Reference *self, PyObject *args, PyObject *kwds)
170170
py_oid_to_git_oid(py_peel, &peel);
171171
}
172172

173-
self->reference = git_reference__alloc(name, &oid, &peel);
173+
self->reference = git_reference__alloc(name, &oid,
174+
py_peel == Py_None ? NULL : &peel);
174175
return 0;
175176
}
176177

test/test_refdb_backend.py

Lines changed: 88 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,14 +25,17 @@
2525

2626
"""Tests for Refdb objects."""
2727

28-
from collections.abc import Generator
28+
import sys
29+
from collections.abc import Generator, Iterator
2930
from pathlib import Path
3031

3132
import pytest
3233

3334
import pygit2
3435
from pygit2 import Commit, Oid, Reference, Repository, Signature
3536

37+
from . import utils
38+
3639

3740
# Note: the refdb abstraction from libgit2 is meant to provide information
3841
# which libgit2 transforms into something more useful, and in general YMMV by
@@ -96,6 +99,41 @@ def repo(testrepo: Repository) -> Generator[Repository, None, None]:
9699
yield testrepo
97100

98101

102+
class CachedRefdbBackend(ProxyRefdbBackend):
103+
"""A backend that caches and reuses the Reference objects it returns."""
104+
105+
def __init__(self, source: pygit2.RefdbBackend) -> None:
106+
super().__init__(source)
107+
self.cache: dict[str, Reference] = {}
108+
109+
def lookup(self, ref: str) -> Reference:
110+
if ref not in self.cache:
111+
self.cache[ref] = self.source.lookup(ref)
112+
return self.cache[ref]
113+
114+
115+
class IterRefdbBackend(ProxyRefdbBackend):
116+
"""A backend whose iterator yields cached Reference objects."""
117+
118+
def __init__(self, source: pygit2.RefdbBackend) -> None:
119+
super().__init__(source)
120+
self.cache: list[Reference] | None = None
121+
self.refs: Iterator[Reference] = iter([])
122+
123+
def __iter__(self) -> 'IterRefdbBackend':
124+
if self.cache is None:
125+
self.cache = [
126+
self.source.lookup('refs/heads/master'),
127+
self.source.lookup('refs/heads/i18n'),
128+
Reference('refs/heads/symbolic', 'refs/heads/master'),
129+
]
130+
self.refs = iter(self.cache)
131+
return self
132+
133+
def __next__(self) -> Reference:
134+
return next(self.refs)
135+
136+
99137
def test_exists(repo: Repository) -> None:
100138
assert not repo.backend.exists('refs/heads/does-not-exist')
101139
assert repo.backend.exists('refs/heads/master')
@@ -106,6 +144,55 @@ def test_lookup(repo: Repository) -> None:
106144
assert repo.backend.lookup('refs/heads/master').name == 'refs/heads/master'
107145

108146

147+
def test_lookup_cached_callback(testrepo: Repository) -> None:
148+
# Regression test: a backend may cache and return the same Reference
149+
# object on every lookup; the callback must not invalidate it, and
150+
# repeated lookups through libgit2 must keep working.
151+
backend = CachedRefdbBackend(pygit2.RefdbFsBackend(testrepo))
152+
refdb = pygit2.Refdb.new(testrepo)
153+
refdb.set_backend(backend)
154+
testrepo.set_refdb(refdb)
155+
156+
target = testrepo.references['refs/heads/master'].target
157+
assert testrepo.references['refs/heads/master'].target == target
158+
assert backend.cache['refs/heads/master'].name == 'refs/heads/master'
159+
160+
161+
def test_iterator_callback(testrepo: Repository) -> None:
162+
# Exercise the custom backend's iterator callback through libgit2's
163+
# git_reference_iterator; the Python attribute alone doesn't install it.
164+
backend = IterRefdbBackend(pygit2.RefdbFsBackend(testrepo))
165+
refdb = pygit2.Refdb.new(testrepo)
166+
refdb.set_backend(backend)
167+
testrepo.set_refdb(refdb)
168+
169+
names = sorted(ref.name for ref in testrepo.references.iterator())
170+
assert names == ['refs/heads/i18n', 'refs/heads/master', 'refs/heads/symbolic']
171+
172+
# The backend's cached objects must still be usable after iteration.
173+
assert backend.cache is not None
174+
assert [ref.name for ref in backend.cache] == [
175+
'refs/heads/master',
176+
'refs/heads/i18n',
177+
'refs/heads/symbolic',
178+
]
179+
180+
181+
@utils.requires_refcount
182+
def test_iterator_callback_no_leak(testrepo: Repository) -> None:
183+
# Iterating must not leak the Reference objects the backend yields.
184+
backend = IterRefdbBackend(pygit2.RefdbFsBackend(testrepo))
185+
refdb = pygit2.Refdb.new(testrepo)
186+
refdb.set_backend(backend)
187+
testrepo.set_refdb(refdb)
188+
189+
list(testrepo.references.iterator())
190+
assert backend.cache is not None
191+
refcount = sys.getrefcount(backend.cache[0])
192+
list(testrepo.references.iterator())
193+
assert sys.getrefcount(backend.cache[0]) == refcount
194+
195+
109196
def test_write(repo: Repository) -> None:
110197
master = repo.backend.lookup('refs/heads/master')
111198
commit = repo[master.target]

0 commit comments

Comments
 (0)