Skip to content

Commit 706d29d

Browse files
authored
Fix potential OOM in Apply (#75)
The apply logic trusted the line counts of the incoming patch when allocating memory for preimages. This meant a patch could specify a very large old position in a fragment and force the library to allocate excessive memory before checking if the source actually contains that many lines. To fix this while keeping the current structure that reads the source incrementally, read the preimage in chunks of 4096 lines. This bounds the memory that might be allocated before we reach the end of the source file. Most practical patches should only require one or two reads. Also fix a related panic that could happen with an old position close to math.MaxInt64.
1 parent 1d1f2a6 commit 706d29d

5 files changed

Lines changed: 83 additions & 8 deletions

File tree

gitdiff/apply_test.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ func TestApplyTextFragment(t *testing.T) {
4040
},
4141
Err: &Conflict{},
4242
},
43+
"errorShortSrcExtreme": {
44+
Files: applyFiles{
45+
Src: "text_fragment_error.src",
46+
Patch: "text_fragment_error_short_src_extreme.patch",
47+
},
48+
Err: &Conflict{},
49+
},
50+
"errorOverflow": {
51+
Files: applyFiles{
52+
Src: "text_fragment_error.src",
53+
Patch: "text_fragment_error_overflow.patch",
54+
},
55+
Err: "overflow",
56+
},
4357
"errorContextConflict": {
4458
Files: applyFiles{
4559
Src: "text_fragment_error.src",

gitdiff/apply_text.go

Lines changed: 43 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package gitdiff
33
import (
44
"errors"
55
"io"
6+
"math"
67
)
78

89
// TextApplier applies changes described in text fragments to source data. If
@@ -66,6 +67,9 @@ func (a *TextApplier) ApplyFragment(f *TextFragment) error {
6667
if fragStart < 0 {
6768
fragStart = 0
6869
}
70+
if f.OldLines > math.MaxInt64-fragStart {
71+
return applyError(errors.New("fragment bounds overflow"))
72+
}
6973
fragEnd := fragStart + f.OldLines
7074

7175
start := a.nextLine
@@ -83,15 +87,9 @@ func (a *TextApplier) ApplyFragment(f *TextFragment) error {
8387
}
8488
}
8589

86-
preimage := make([][]byte, fragEnd-start)
87-
n, err := a.lineSrc.ReadLinesAt(preimage, start)
90+
preimage, err := readPreimage(a.lineSrc, start, fragEnd-start)
8891
if err != nil {
89-
// an EOF indicates that source file is shorter than the patch expects,
90-
// which should be reported as a conflict rather than a generic error
91-
if errors.Is(err, io.EOF) {
92-
err = &Conflict{"src has fewer lines than required by fragment"}
93-
}
94-
return applyError(err, lineNum(start+int64(n)))
92+
return applyError(err)
9593
}
9694

9795
// copy leading data before the fragment starts
@@ -131,6 +129,43 @@ func (a *TextApplier) ApplyFragment(f *TextFragment) error {
131129
return nil
132130
}
133131

132+
// readPreimage attempts to read lines from the reader in chunks to avoid
133+
// allocating too much memory if the expected line count is longer than the
134+
// actual input.
135+
func readPreimage(r LineReaderAt, start int64, lines int64) ([][]byte, error) {
136+
// This chunk size is arbitrary, but is large enough that most preimages
137+
// should read in a single chunk. It's generally safe to pick a large chunk
138+
// size, as the chunk only allocates slice headers for the line content,
139+
// with the actual content only allocated if it exists in the source. With
140+
// a chunk size of 4096, we allocate at most ~96KB extra before detecting
141+
// the short source in the worst case.
142+
const chunkSize = 4096
143+
144+
chunks := ((lines - 1) / chunkSize) + 1
145+
remaining := lines
146+
147+
var preimage [][]byte
148+
for c := int64(0); c < chunks; c++ {
149+
readSize := min(chunkSize, remaining)
150+
151+
i := int64(len(preimage))
152+
preimage = append(preimage, make([][]byte, readSize)...)
153+
154+
n, err := r.ReadLinesAt(preimage[i:i+readSize], start)
155+
if err != nil {
156+
// an EOF indicates that source file is shorter than the patch expects,
157+
// which should be reported as a conflict rather than a generic error
158+
if errors.Is(err, io.EOF) {
159+
err = &Conflict{"src has fewer lines than required by fragment"}
160+
}
161+
return nil, applyError(err, lineNum(start+int64(n)))
162+
}
163+
start += int64(n)
164+
remaining -= int64(n)
165+
}
166+
return preimage, nil
167+
}
168+
134169
func applyTextLine(dst io.Writer, line Line, preimage [][]byte, i int64) (err error) {
135170
if line.Old() && string(preimage[i]) != line.Line {
136171
return &Conflict{"fragment line does not match src line"}

gitdiff/assert_test.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import (
77
)
88

99
func assertError(t *testing.T, expected any, actual error, action string) {
10+
t.Helper()
11+
1012
if actual == nil {
1113
t.Fatalf("expected error %s, but got nil", action)
1214
}
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
diff --git a/gitdiff/testdata/apply/text_fragment_error.src b/gitdiff/testdata/apply/text_fragment_error.src
2+
--- a/gitdiff/testdata/apply/text_fragment_error.src
3+
+++ b/gitdiff/testdata/apply/text_fragment_error.src
4+
@@ -9223372036854775803,7 +9223372036854775803,7 @@ line 14
5+
line 15
6+
line 16
7+
line 17
8+
-line 18
9+
+new line a
10+
line 19
11+
line 20
12+
line 21
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
diff --git a/gitdiff/testdata/apply/text_fragment_error.src b/gitdiff/testdata/apply/text_fragment_error.src
2+
--- a/gitdiff/testdata/apply/text_fragment_error.src
3+
+++ b/gitdiff/testdata/apply/text_fragment_error.src
4+
@@ -1152921504606846976,7 +1152921504606846976,7 @@ line 14
5+
line 15
6+
line 16
7+
line 17
8+
-line 18
9+
+new line a
10+
line 19
11+
line 20
12+
line 21

0 commit comments

Comments
 (0)