Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions gitdiff/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ var (
// If an error occurs while applying, Apply returns an *ApplyError that
// annotates the error with additional information. If the error is because of
// a conflict with the source, the wrapped error will be a *Conflict.
func Apply(dst io.Writer, src io.ReaderAt, f *File) error {
func Apply(dst io.Writer, src io.ReaderAt, f *File, opts ...ApplyOption) error {
if f.IsBinary {
if len(f.TextFragments) > 0 {
return applyError(errors.New("binary file contains text fragments"))
Expand All @@ -113,7 +113,7 @@ func Apply(dst io.Writer, src io.ReaderAt, f *File) error {

switch {
case f.BinaryFragment != nil:
applier := NewBinaryApplier(dst, src)
applier := NewBinaryApplier(dst, src, opts...)
if err := applier.ApplyFragment(f.BinaryFragment); err != nil {
return err
}
Expand All @@ -131,7 +131,7 @@ func Apply(dst io.Writer, src io.ReaderAt, f *File) error {
// right now, the application fails if fragments overlap, but it should be
// possible to precompute the result of applying them in order

applier := NewTextApplier(dst, src)
applier := NewTextApplier(dst, src, opts...)
for i, frag := range frags {
if err := applier.ApplyFragment(frag); err != nil {
return applyError(err, fragNum(i))
Expand Down
66 changes: 52 additions & 14 deletions gitdiff/apply_binary.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,21 @@ import (
// BinaryApplier applies binary changes described in a fragment to source data.
// The applier must be closed after use.
type BinaryApplier struct {
dst io.Writer
src io.ReaderAt
dst io.Writer
src io.ReaderAt
opts applyOptions

closed bool
dirty bool
}

// NewBinaryApplier creates an BinaryApplier that reads data from src and
// writes modified data to dst.
func NewBinaryApplier(dst io.Writer, src io.ReaderAt) *BinaryApplier {
func NewBinaryApplier(dst io.Writer, src io.ReaderAt, opts ...ApplyOption) *BinaryApplier {
a := BinaryApplier{
dst: dst,
src: src,
dst: dst,
src: src,
opts: collectApplyOptions(opts),
}
return &a
}
Expand All @@ -44,9 +46,17 @@ func (a *BinaryApplier) ApplyFragment(f *BinaryFragment) error {
return applyError(errApplyInProgress)
}

// mark an apply as in progress, even if it fails before making changes
// Mark an apply as in progress, even if it fails before making changes
a.dirty = true

// Verify the binary data does not exceed the limit before decompressing
// it. The reader from Data() will not read more that f.Size+1 bytes, so we
// only need to check the expected size against the limit.
limit := a.opts.maxBinaryFragmentBytes
if limit >= 0 && f.Size > limit {
return applyError(fmt.Errorf("binary fragment size of %d exceeds %d byte limit", f.Size, limit))
}

switch f.Method {
case BinaryPatchLiteral:
if _, err := io.Copy(a.dst, f.Data()); err != nil {
Expand All @@ -57,7 +67,7 @@ func (a *BinaryApplier) ApplyFragment(f *BinaryFragment) error {
if err != nil {
return applyError(err)
}
if err := applyBinaryDeltaFragment(a.dst, a.src, data); err != nil {
if err := applyBinaryDeltaFragment(a.dst, a.src, data, limit); err != nil {
return applyError(err)
}
default:
Expand All @@ -82,36 +92,47 @@ func (a *BinaryApplier) Close() (err error) {
return err
}

func applyBinaryDeltaFragment(dst io.Writer, src io.ReaderAt, frag []byte) error {
func applyBinaryDeltaFragment(dst io.Writer, src io.ReaderAt, frag []byte, limit int64) error {
srcSize, delta := readBinaryDeltaSize(frag)
if err := checkBinarySrcSize(src, srcSize); err != nil {
return err
}

// As a form of compression, delta instructions can create a lot of data in
// the destination from a small instruction set. First, check the expected
// size against the limit. Then, while executing instructions, stop as soon
// as a write exceeds the expected limit.
dstSize, delta := readBinaryDeltaSize(delta)
if limit >= 0 && dstSize > limit {
return fmt.Errorf("binary delta size of %d exceeds %d byte limit", dstSize, limit)
}

dstLimit := &limitWriter{
w: dst,
limit: dstSize,
limitErr: errors.New("corrupt binary delta: extra data"),
}

for len(delta) > 0 {
op := delta[0]
if op == 0 {
return errors.New("invalid delta opcode 0")
}

var n int64
var err error
switch op & 0x80 {
case 0x80:
n, delta, err = applyBinaryDeltaCopy(dst, op, delta[1:], src)
_, delta, err = applyBinaryDeltaCopy(dstLimit, op, delta[1:], src)
case 0x00:
n, delta, err = applyBinaryDeltaAdd(dst, op, delta[1:])
_, delta, err = applyBinaryDeltaAdd(dstLimit, op, delta[1:])
}
if err != nil {
return err
}
dstSize -= n
}

if dstSize != 0 {
return errors.New("corrupt binary delta: insufficient or extra data")
if dstLimit.limit > 0 {
return errors.New("corrupt binary delta: insufficient data")
}
return nil
}
Expand Down Expand Up @@ -208,3 +229,20 @@ func checkBinarySrcSize(r io.ReaderAt, size int64) error {
}
return nil
}

// limitWriter is an io.Writer that writes at most limit bytes to the wrapped
// io.Writer, then returns limitErr for any Write calls that exceed the limit.
type limitWriter struct {
w io.Writer
limit int64
limitErr error
}

func (w *limitWriter) Write(p []byte) (n int, err error) {
if int64(len(p)) > w.limit {
return 0, w.limitErr
}
n, err = w.w.Write(p)
w.limit -= int64(n)
return
}
44 changes: 44 additions & 0 deletions gitdiff/apply_options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package gitdiff

const (
// This default size is arbitrary, but 100MiB matches the largest allowed
// size of a file in GitHub.com as of July 2026. That suggests that 100MiB
// should cover most practical patches the library will encounter.
defaultMaxBinaryFragmentBytes = 100 * 1024 * 1024
)

type applyOptions struct {
maxBinaryFragmentBytes int64
}

// An ApplyOption modifies the behavior of [TextApplier] and [BinaryApplier].
type ApplyOption func(*applyOptions)

// WithMaxBinaryFragmentBytes sets the maximum size in bytes of any individual
// binary fragment in a patch. Passing a negative value disables the limit,
// allowing fragments of any size.
//
// When applying BinaryPatchLiteral fragments, this limits the size of the
// decompressed fragment content. When applying BinaryPatchDelta fragments,
// this independently limits both the size of the decompressed delta
// instruction stream and the size of the data generated by executing the
// instructions.
//
// Passing this option to [TextApplier] has no effect.
//
// The default value if unspecified is 100MiB.
func WithMaxBinaryFragmentBytes(b int64) ApplyOption {
return func(opts *applyOptions) {
opts.maxBinaryFragmentBytes = b
}
}

func collectApplyOptions(allOpts []ApplyOption) applyOptions {
opts := applyOptions{
maxBinaryFragmentBytes: defaultMaxBinaryFragmentBytes,
}
for _, opt := range allOpts {
opt(&opts)
}
return opts
}
59 changes: 50 additions & 9 deletions gitdiff/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ func TestApplyTextFragment(t *testing.T) {
if len(file.TextFragments) != 1 {
t.Fatalf("patch should contain exactly one fragment, but it has %d", len(file.TextFragments))
}
applier := NewTextApplier(dst, src)
applier := NewTextApplier(dst, src, test.Options...)
return applier.ApplyFragment(file.TextFragments[0])
})
})
Expand All @@ -97,6 +97,16 @@ func TestApplyBinaryFragment(t *testing.T) {
"deltaModify": {Files: getApplyFiles("bin_fragment_delta_modify")},
"deltaModifyLarge": {Files: getApplyFiles("bin_fragment_delta_modify_large")},

"errorLiteralExceedsLimit": {
Files: applyFiles{
Src: "bin_fragment_literal_create.src",
Patch: "bin_fragment_literal_error_limit.patch",
},
Options: []ApplyOption{
WithMaxBinaryFragmentBytes(200),
},
Err: "exceeds 200 byte limit",
},
"errorIncompleteAdd": {
Files: applyFiles{
Src: "bin_fragment_delta_error.src",
Expand All @@ -111,26 +121,56 @@ func TestApplyBinaryFragment(t *testing.T) {
},
Err: "incomplete copy",
},
"errorSrcSize": {
"errorDeltaIncorrectSrcSize": {
Files: applyFiles{
Src: "bin_fragment_delta_error.src",
Patch: "bin_fragment_delta_error_src_size.patch",
},
Err: &Conflict{},
},
"errorDstSize": {
"errorDeltaDstSizeShort": {
Files: applyFiles{
Src: "bin_fragment_delta_error.src",
Patch: "bin_fragment_delta_error_dst_size_short.patch",
},
Err: "insufficient",
},
"errorDeltaDstSizeExceedsLimit": {
Files: applyFiles{
Src: "bin_fragment_delta_error.src",
Patch: "bin_fragment_delta_error_dst_size.patch",
Patch: "bin_fragment_delta_error_dst_size_limit.patch",
},
Options: []ApplyOption{
WithMaxBinaryFragmentBytes(200),
},
Err: "exceeds 200 byte limit",
},
"errorDeltaExceedsLimitOnAdd": {
Files: applyFiles{
Src: "bin_fragment_delta_error.src",
Patch: "bin_fragment_delta_error_limit_add.patch",
},
Options: []ApplyOption{
WithMaxBinaryFragmentBytes(1024),
},
Err: "extra data",
},
"errorDeltaExceedsLimitOnCopy": {
Files: applyFiles{
Src: "bin_fragment_delta_error.src",
Patch: "bin_fragment_delta_error_limit_copy.patch",
},
Options: []ApplyOption{
WithMaxBinaryFragmentBytes(1024),
},
Err: "insufficient or extra data",
Err: "extra data",
},
}

for name, test := range tests {
t.Run(name, func(t *testing.T) {
test.run(t, func(dst io.Writer, src io.ReaderAt, file *File) error {
applier := NewBinaryApplier(dst, src)
applier := NewBinaryApplier(dst, src, test.Options...)
return applier.ApplyFragment(file.BinaryFragment)
})
})
Expand Down Expand Up @@ -171,15 +211,16 @@ func TestApplyFile(t *testing.T) {
for name, test := range tests {
t.Run(name, func(t *testing.T) {
test.run(t, func(dst io.Writer, src io.ReaderAt, file *File) error {
return Apply(dst, src, file)
return Apply(dst, src, file, test.Options...)
})
})
}
}

type applyTest struct {
Files applyFiles
Err any
Files applyFiles
Options []ApplyOption
Err any
}

func (at applyTest) run(t *testing.T, apply func(io.Writer, io.ReaderAt, *File) error) {
Expand Down
8 changes: 5 additions & 3 deletions gitdiff/apply_text.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,17 +17,19 @@ type TextApplier struct {
src io.ReaderAt
lineSrc LineReaderAt
nextLine int64
opts applyOptions

closed bool
dirty bool
}

// NewTextApplier creates a TextApplier that reads data from src and writes
// modified data to dst. If src implements LineReaderAt, it is used directly.
func NewTextApplier(dst io.Writer, src io.ReaderAt) *TextApplier {
func NewTextApplier(dst io.Writer, src io.ReaderAt, opts ...ApplyOption) *TextApplier {
a := TextApplier{
dst: dst,
src: src,
dst: dst,
src: src,
opts: collectApplyOptions(opts),
}

if lineSrc, ok := src.(LineReaderAt); ok {
Expand Down
Loading
Loading