Skip to content

Commit 7b24f69

Browse files
committed
*: Fix memory corruptions caused by improper git2go usage
Alain reports that lab.nexedi.com backup restoration sometimes fails with error like ... # file gitlab/misc -> .../srv/backup/backup-gitlab.git/gitlab-backup.Pj0fpp/gitlab_backup/db/database.pgdump/7630.dat/7630.dat.ry main.cmd_restore: main.blob_to_file: write .../srv/backup/backup-gitlab.git/gitlab-backup.Pj0fpp/gitlab_backup/db/database.pgdump/7630.dat/7630.dat.ry: bad address which means that write system call invoked by writefile at tail of blob_to_file returned EFAULT. The blob_to_file function is organized approximately as this: blob_to_file(blob_sha1, path) { blob = ReadObject(blob_sha1, git.ObjectBlob) blob_content = blob.Data() writefile(path, blob_content) } and getting EFAULT inside writefile means that blob_content points to some unmapped memory. How that could be? The answer is that blob.Data(), as implemented by git2go, returns []byte that points to Cgo memory owned by blob object, and the blob object has finalizer that frees that memory, which sometimes leads to libc allocator to also return freed region completely back to OS by doing munmap: https://github.com/libgit2/git2go/blob/v31.7.9-0-gcbca5b8/odb.go#L345-L359 https://github.com/libgit2/git2go/blob/v31.7.9-0-gcbca5b8/odb.go#L162-L177 https://github.com/libgit2/git2go/blob/v31.7.9-0-gcbca5b8/odb.go#L322-L325 and if that happens we see the EFAULT, but if no munmap happens we can be saving corrupt data to restored file. The OdbObject.Data even has comment about that - that one needs to keep the object alive until retrieved data is used: // Data returns a slice pointing to the unmanaged object memory. You must make // sure the object is referenced for at least as long as the slice is used. func (object *OdbObject) Data() (data []byte) { but this comment was added in 2017 in libgit2/git2go@55a109614151 as part of libgit2/git2go#393 while doing "KeepAlive all the things" to fix segmentation faults and other misbehaviours. I missed all that because we switched blob_to_file from `git cat-file` to git2go in 2016 in fbd72c0 (Switch file_to_blob() and blob_to_file() to work without spawning Git subprocesses) and we never actively worked on that part of code anymore. For the reference the git2go introduction to git-backup happened on that same day in 2016 in 624393d (Hook in git2go (cgo bindings to libgit2)). The problem of memory corruption inside blob_to_file can be reliably reproduced via injecting the following patch blob_to_file(blob_sha1, path) { blob = ReadObject(blob_sha1, git.ObjectBlob) blob_content = blob.Data() + runtime.GC() writefile(path, blob_content) } which leads to e.g. the following test failure at every test run: === RUN TestPullRestore ... # file b1 -> /tmp/t-git-backup2575257088/1/symlink.file git-backup_test.go:109: git-backup_test.go:297: lab.nexedi.com/kirr/git-backup.cmd_restore: lab.nexedi.com/kirr/git-backup.blob_to_file: symlink ^D<80><8c>þ�^@^@^2h space + α /tmp/t-git-backup2575257088/1/symlink.file: invalid argument and the memory corruption can be fixed reliably by adding proper runtime.KeepAlive so that the blob object assuredly stays alive during writefile call: blob_to_file(blob_sha1, path) { blob = ReadObject(blob_sha1, git.ObjectBlob) blob_content = blob.Data() writefile(path, blob_content) + runtime.KeepAlive(blob) } However going through git2go code it could be seen that it is full of Go <-> C interactions and given that there is a track records of catching many crashes due to not getting lifetime management right (see e.g. libgit2/git2go#352, libgit2/git2go#334, libgit2/git2go#553, libgit2/git2go#513, libgit2/git2go#373, libgit2/git2go#387 and once again libgit2/git2go#393) there is no guarantee that no any other similar issue is there anywhere else besides OdbObject.Data(). With that we either need to put a lot of runtime.KeepAlive after every interaction with git2go, and put it properly, switch back to `git cat-file` and similar things reverting fbd72c0 and friends, or do something else. As fbd72c0 explains switching back to `git cat-file` will slowdown files restoration by an order of magnitude. Putting runtime.KeepAlive is also not practical because it is hard to see all the places where we interact with git2go, even indirectly, and so it is easy to make mistakes. -> Thus let's keep the code that interacts with git2go well localized (done by previous patch), and let's make a copy over every string or []byte object we receive from git2go with adding careful runtime.KeepAlive post after that. This fixes the problem of blob_to_file data corruption and it should fix all other potential memory corruption problems we might ever have with git2go due to potentially improper usage on git-backup side. The copy cost is smaller compared to the cost of either spawning e.g. `git cat-file` for every object, or interacting with `git cat-file --batch` server spawned once, but still spending context switches on every request and still making the copy on socket or pipe transfer. But most of all the copy cost is negligible to the cost of catching hard to reproduce crashes or data corruptions in the production environment. For the reference the time it takes to restore "files" part of lab.nexedi.com backup was ~ 1m51s before this patch, and became ~ 1m55s after this patch indicating ~ 3.5% slowdown for that part. Which could be said as noticeable but not big, and since most of the time is spent during git pack restoration, taking much more time than files, those several seconds of slowdown become completely negligible. /reported-by @alain.takoudjou, @tomo /reported-at https://www.erp5.com/group_section/forum/Gitlab-backup-zDVMZqaMAK/view?list_start=15&reset=1#2074747282 /cc @jerome, @rafael /reviewed-on https://lab.nexedi.com/kirr/git-backup/-/merge_requests/12
1 parent 961ca00 commit 7b24f69

File tree

7 files changed

+243
-14
lines changed

7 files changed

+243
-14
lines changed

git-backup.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,11 +156,16 @@ func file_to_blob(g *git.Repository, path string) (Sha1, uint32) {
156156
}
157157

158158
// blob_sha1, mode -> file
159+
var tblob_to_file_mid_hook func()
159160
func blob_to_file(g *git.Repository, blob_sha1 Sha1, mode uint32, path string) {
160161
blob, err := ReadObject(g, blob_sha1, git.ObjectBlob)
161162
exc.Raiseif(err)
162163
blob_content := blob.Data()
163164

165+
if tblob_to_file_mid_hook != nil {
166+
tblob_to_file_mid_hook() // we used to corrupt memory if GC is invoked right here
167+
}
168+
164169
err = os.MkdirAll(pathpkg.Dir(path), 0777)
165170
exc.Raiseif(err)
166171

git-backup_test.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"os/exec"
2828
"path/filepath"
2929
"regexp"
30+
"runtime"
3031
"strings"
3132
"syscall"
3233
"testing"
@@ -447,3 +448,11 @@ func TestRepoRefSplit(t *testing.T) {
447448
}
448449
}
449450
}
451+
452+
453+
// blob_to_file used to corrupt memory if GC triggers inside it
454+
func init() {
455+
tblob_to_file_mid_hook = func() {
456+
runtime.GC()
457+
}
458+
}

internal/git/bytes_go1.19.go

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
// Copyright (C) 2025 Nexedi SA and Contributors.
2+
// Kirill Smelkov <kirr@nexedi.com>
3+
//
4+
// This program is free software: you can Use, Study, Modify and Redistribute
5+
// it under the terms of the GNU General Public License version 3, or (at your
6+
// option) any later version, as published by the Free Software Foundation.
7+
//
8+
// You can also Link and Combine this program with other software covered by
9+
// the terms of any of the Free Software licenses or any of the Open Source
10+
// Initiative approved licenses and Convey the resulting work. Corresponding
11+
// source of such a combination shall include the source code for all other
12+
// software used.
13+
//
14+
// This program is distributed WITHOUT ANY WARRANTY; without even the implied
15+
// warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
16+
//
17+
// See COPYING file for full licensing terms.
18+
// See https://www.nexedi.com/licensing for rationale and options.
19+
20+
//go:build !go1.20
21+
// +build !go1.20
22+
23+
package git
24+
25+
26+
func bytesClone(b []byte) []byte {
27+
if b == nil {
28+
return nil
29+
}
30+
b2 := make([]byte, len(b))
31+
copy(b2, b)
32+
return b2
33+
}

internal/git/bytes_go1.20.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (C) 2025 Nexedi SA and Contributors.
2+
// Kirill Smelkov <kirr@nexedi.com>
3+
//
4+
// This program is free software: you can Use, Study, Modify and Redistribute
5+
// it under the terms of the GNU General Public License version 3, or (at your
6+
// option) any later version, as published by the Free Software Foundation.
7+
//
8+
// You can also Link and Combine this program with other software covered by
9+
// the terms of any of the Free Software licenses or any of the Open Source
10+
// Initiative approved licenses and Convey the resulting work. Corresponding
11+
// source of such a combination shall include the source code for all other
12+
// software used.
13+
//
14+
// This program is distributed WITHOUT ANY WARRANTY; without even the implied
15+
// warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
16+
//
17+
// See COPYING file for full licensing terms.
18+
// See https://www.nexedi.com/licensing for rationale and options.
19+
20+
//go:build go1.20
21+
// +build go1.20
22+
23+
package git
24+
25+
import (
26+
"bytes"
27+
)
28+
29+
30+
func bytesClone(b []byte) []byte {
31+
return bytes.Clone(b)
32+
}

internal/git/git.go

Lines changed: 102 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -17,13 +17,45 @@
1717
// See COPYING file for full licensing terms.
1818
// See https://www.nexedi.com/licensing for rationale and options.
1919

20-
// Package internal/git wraps package git2go.
20+
// Package internal/git wraps package git2go with providing unconditional safety.
21+
//
22+
// For example git2go.Object.Data() returns []byte that aliases unsafe memory
23+
// that can go away from under []byte if original Object is garbage collected.
24+
// The following code snippet is thus _not_ correct:
25+
//
26+
// obj = odb.Read(sha1)
27+
// data = obj.Data()
28+
// ... use data
29+
//
30+
// because obj can be garbage-collected right after `data = obj.Data()` but
31+
// before `use data` leading to either crashes or memory corruption. A
32+
// runtime.KeepAlive(obj) needs to be added to the end of the snippet - after
33+
// `use data` - to make that code correct.
34+
//
35+
// Given that obj.Data() is not "speaking" by itself as unsafe, and that there
36+
// are many similar methods, it is hard to see which places in the code needs
37+
// special attention.
38+
//
39+
// For this reason git-backup took decision to localize git2go-related code in
40+
// one small place here, and to expose only safe things to outside. That is we
41+
// make data copies when reading object data and similar things to provide
42+
// unconditional safety to the caller via that copy cost.
43+
//
44+
// The copy cost is smaller compared to the cost of either spawning e.g. `git
45+
// cat-file` for every object, or interacting with `git cat-file --batch`
46+
// server spawned once, but still spending context switches on every request
47+
// and still making the copy on socket or pipe transfer. But most of all the
48+
// copy cost is negligible to the cost of catching hard to reproduce crashes or
49+
// data corruptions in the production environment.
2150
package git
2251

2352
import (
53+
"runtime"
54+
2455
git2go "github.com/libgit2/git2go/v31"
2556
)
2657

58+
// constants are safe to propagate as is.
2759
const (
2860
ObjectAny = git2go.ObjectAny
2961
ObjectInvalid = git2go.ObjectInvalid
@@ -34,39 +66,49 @@ const (
3466
)
3567

3668

69+
// types that are safe to propagate as is.
3770
type (
38-
ObjectType = git2go.ObjectType
39-
Oid = git2go.Oid
40-
Signature = git2go.Signature
41-
TreeEntry = git2go.TreeEntry
71+
ObjectType = git2go.ObjectType // int
72+
Oid = git2go.Oid // [20]byte ; cloned when retrieved
73+
Signature = git2go.Signature // struct with strings ; strings are cloned when retrieved
74+
TreeEntry = git2go.TreeEntry // struct with sting, Oid, ... ; strings and oids are cloned when retrieved
4275
)
4376

4477

78+
// types that we wrap to provide safety.
79+
80+
// Repository provides safe wrapper over git2go.Repository .
4581
type Repository struct {
4682
repo *git2go.Repository
4783
References *ReferenceCollection
4884
}
4985

86+
// ReferenceCollection provides safe wrapper over git2go.ReferenceCollection .
5087
type ReferenceCollection struct {
5188
r *Repository
5289
}
5390

91+
// Reference provides safe wrapper over git2go.Reference .
5492
type Reference struct {
5593
ref *git2go.Reference
5694
}
5795

96+
// Commit provides safe wrapper over git2go.Commit .
5897
type Commit struct {
5998
commit *git2go.Commit
6099
}
61100

101+
// Tree provides safe wrapper over git2go.Tree .
62102
type Tree struct {
63103
tree *git2go.Tree
64104
}
65105

106+
// Odb provides safe wrapper over git2go.Odb .
66107
type Odb struct {
67108
odb *git2go.Odb
68109
}
69110

111+
// OdbObject provides safe wrapper over git2go.OdbObject .
70112
type OdbObject struct {
71113
obj *git2go.OdbObject
72114
}
@@ -125,43 +167,89 @@ func (o *Odb) Read(oid *Oid) (*OdbObject, error) {
125167
}
126168

127169

128-
// wrappers over methods
170+
// wrappers over safe methods
129171

130172
func (c *Commit) ParentCount() uint { return c.commit.ParentCount() }
131173
func (o *OdbObject) Type() ObjectType { return o.obj.Type() }
132174

133175

176+
// wrappers over unsafe, or potentially unsafe methods
177+
134178
func (r *Repository) Path() string {
135-
return r.repo.Path()
179+
path := stringsClone( r.repo.Path() )
180+
runtime.KeepAlive(r)
181+
return path
136182
}
137183

138184
func (r *Repository) DefaultSignature() (*Signature, error) {
139-
return r.repo.DefaultSignature()
185+
s, err := r.repo.DefaultSignature()
186+
if s != nil {
187+
s = &Signature{
188+
Name: stringsClone(s.Name),
189+
Email: stringsClone(s.Email),
190+
When: s.When,
191+
}
192+
}
193+
runtime.KeepAlive(r)
194+
return s, err
140195
}
141196

142197

143198
func (c *Commit) Message() string {
144-
return c.commit.Message()
199+
msg := stringsClone( c.commit.Message() )
200+
runtime.KeepAlive(c)
201+
return msg
145202
}
146203

147204
func (c *Commit) ParentId(n uint) *Oid {
148-
return c.commit.ParentId(n)
205+
pid := oidClone( c.commit.ParentId(n) )
206+
runtime.KeepAlive(c)
207+
return pid
149208
}
150209

151210
func (t *Tree) EntryByName(filename string) *TreeEntry {
152-
return t.tree.EntryByName(filename)
211+
e := t.tree.EntryByName(filename)
212+
if e != nil {
213+
e = &TreeEntry{
214+
Name: stringsClone(e.Name),
215+
Id: oidClone(e.Id),
216+
Type: e.Type,
217+
Filemode: e.Filemode,
218+
}
219+
}
220+
runtime.KeepAlive(t)
221+
return e
153222
}
154223

155224

156225
func (o *Odb) Write(data []byte, otype ObjectType) (*Oid, error) {
157-
return o.odb.Write(data, otype)
226+
oid, err := o.odb.Write(data, otype)
227+
oid = oidClone(oid)
228+
runtime.KeepAlive(o)
229+
return oid, err
158230
}
159231

160232

161233
func (o *OdbObject) Id() *Oid {
162-
return o.obj.Id()
234+
id := oidClone( o.obj.Id() )
235+
runtime.KeepAlive(o)
236+
return id
163237
}
164238

165239
func (o *OdbObject) Data() []byte {
166-
return o.obj.Data()
240+
data := bytesClone( o.obj.Data() )
241+
runtime.KeepAlive(o)
242+
return data
243+
}
244+
245+
246+
// misc
247+
248+
func oidClone(oid *Oid) *Oid {
249+
var oid2 Oid
250+
if oid == nil {
251+
return nil
252+
}
253+
copy(oid2[:], oid[:])
254+
return &oid2
167255
}

internal/git/strings_go1.17.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
// Copyright (C) 2025 Nexedi SA and Contributors.
2+
// Kirill Smelkov <kirr@nexedi.com>
3+
//
4+
// This program is free software: you can Use, Study, Modify and Redistribute
5+
// it under the terms of the GNU General Public License version 3, or (at your
6+
// option) any later version, as published by the Free Software Foundation.
7+
//
8+
// You can also Link and Combine this program with other software covered by
9+
// the terms of any of the Free Software licenses or any of the Open Source
10+
// Initiative approved licenses and Convey the resulting work. Corresponding
11+
// source of such a combination shall include the source code for all other
12+
// software used.
13+
//
14+
// This program is distributed WITHOUT ANY WARRANTY; without even the implied
15+
// warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
16+
//
17+
// See COPYING file for full licensing terms.
18+
// See https://www.nexedi.com/licensing for rationale and options.
19+
20+
//go:build !go1.18
21+
// +build !go1.18
22+
23+
package git
24+
25+
26+
func stringsClone(s string) string {
27+
b := make([]byte, len(s))
28+
copy(b, s)
29+
return string(b)
30+
}

internal/git/strings_go1.18.go

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright (C) 2025 Nexedi SA and Contributors.
2+
// Kirill Smelkov <kirr@nexedi.com>
3+
//
4+
// This program is free software: you can Use, Study, Modify and Redistribute
5+
// it under the terms of the GNU General Public License version 3, or (at your
6+
// option) any later version, as published by the Free Software Foundation.
7+
//
8+
// You can also Link and Combine this program with other software covered by
9+
// the terms of any of the Free Software licenses or any of the Open Source
10+
// Initiative approved licenses and Convey the resulting work. Corresponding
11+
// source of such a combination shall include the source code for all other
12+
// software used.
13+
//
14+
// This program is distributed WITHOUT ANY WARRANTY; without even the implied
15+
// warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
16+
//
17+
// See COPYING file for full licensing terms.
18+
// See https://www.nexedi.com/licensing for rationale and options.
19+
20+
//go:build go1.18
21+
// +build go1.18
22+
23+
package git
24+
25+
import (
26+
"strings"
27+
)
28+
29+
30+
func stringsClone(s string) string {
31+
return strings.Clone(s)
32+
}

0 commit comments

Comments
 (0)