diff --git a/gitdiff/apply.go b/gitdiff/apply.go index eb01c19..e1c7209 100644 --- a/gitdiff/apply.go +++ b/gitdiff/apply.go @@ -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")) @@ -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 } @@ -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)) diff --git a/gitdiff/apply_binary.go b/gitdiff/apply_binary.go index 1668aca..4864b69 100644 --- a/gitdiff/apply_binary.go +++ b/gitdiff/apply_binary.go @@ -9,8 +9,9 @@ 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 @@ -18,10 +19,11 @@ type BinaryApplier struct { // 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 } @@ -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 { @@ -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: @@ -82,13 +92,26 @@ 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] @@ -96,22 +119,20 @@ func applyBinaryDeltaFragment(dst io.Writer, src io.ReaderAt, frag []byte) error 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 } @@ -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 +} diff --git a/gitdiff/apply_options.go b/gitdiff/apply_options.go new file mode 100644 index 0000000..de60165 --- /dev/null +++ b/gitdiff/apply_options.go @@ -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 +} diff --git a/gitdiff/apply_test.go b/gitdiff/apply_test.go index 7edf507..c43e9c6 100644 --- a/gitdiff/apply_test.go +++ b/gitdiff/apply_test.go @@ -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]) }) }) @@ -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", @@ -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) }) }) @@ -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) { diff --git a/gitdiff/apply_text.go b/gitdiff/apply_text.go index 3e307b7..0948178 100644 --- a/gitdiff/apply_text.go +++ b/gitdiff/apply_text.go @@ -17,6 +17,7 @@ type TextApplier struct { src io.ReaderAt lineSrc LineReaderAt nextLine int64 + opts applyOptions closed bool dirty bool @@ -24,10 +25,11 @@ type TextApplier struct { // 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 { diff --git a/gitdiff/testdata/apply/bin.go b/gitdiff/testdata/apply/bin.go index 49dfd27..a705542 100644 --- a/gitdiff/testdata/apply/bin.go +++ b/gitdiff/testdata/apply/bin.go @@ -10,49 +10,20 @@ package main import ( "bytes" "compress/zlib" - "encoding/binary" + "encoding/hex" "flag" "io" - "io/ioutil" "log" "os" - "strings" + "unicode" "github.com/bluekeyes/go-gitdiff/gitdiff" ) -var ( - b85Powers = []uint32{52200625, 614125, 7225, 85, 1} - b85Alpha = []byte( - "0123456789" + "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + "abcdefghijklmnopqrstuvwxyz" + "!#$%&()*+-;<=>?@^_`{|}~", - ) -) - var mode string var compression bool - -func base85Encode(data []byte) []byte { - chunks, remaining := len(data)/4, len(data)%4 - if remaining > 0 { - data = append(data, make([]byte, 4-remaining)...) - chunks++ - } - - var n int - out := make([]byte, 5*chunks) - - for i := 0; i < len(data); i += 4 { - v := binary.BigEndian.Uint32(data[i : i+4]) - for j := 0; j < 5; j++ { - p := v / b85Powers[j] - out[n+j] = b85Alpha[p] - v -= b85Powers[j] * p - } - n += 5 - } - - return out -} +var hexInput bool +var method string func compress(data []byte) ([]byte, error) { var b bytes.Buffer @@ -68,29 +39,11 @@ func compress(data []byte) ([]byte, error) { return b.Bytes(), nil } -func wrap(data []byte) string { - var s strings.Builder - for i := 0; i < len(data); i += 52 { - c := 52 - if c > len(data)-i { - c = len(data) - i - } - b := (c / 5) * 4 - - if b <= 26 { - s.WriteByte(byte('A' + b - 1)) - } else { - s.WriteByte(byte('a' + b - 27)) - } - s.Write(data[i : i+c]) - s.WriteByte('\n') - } - return s.String() -} - func init() { flag.StringVar(&mode, "mode", "parse", "operation mode, one of 'parse' or 'encode'") flag.BoolVar(&compression, "compression", true, "if true, decompress data in 'parse' mode and compress it in 'encode' mode") + flag.BoolVar(&hexInput, "hex", false, "in encode mode, treat the input as a hexidecimal string (removing whitespace and comments)") + flag.StringVar(&method, "method", "literal", "in encode mode, the method of the binary fragment, one of 'literal' or 'delta'") } func main() { @@ -121,19 +74,65 @@ func main() { } case "encode": - data, err := ioutil.ReadAll(os.Stdin) + var frag gitdiff.BinaryFragment + switch method { + case "literal": + frag.Method = gitdiff.BinaryPatchLiteral + case "delta": + frag.Method = gitdiff.BinaryPatchDelta + default: + log.Fatalf("invalid method: %s", method) + } + + data, err := io.ReadAll(os.Stdin) if err != nil { log.Fatalf("failed to read input: %v", err) } + + if hexInput { + data, err = decodeHexInput(data) + if err != nil { + log.Fatalf("input contains invalid hex data: %v", err) + } + } + + // Size is always for the uncompressed data, set it before compressing + frag.Size = int64(len(data)) + if compression { data, err = compress(data) if err != nil { log.Fatalf("failed to compress data: %v", err) } } - os.Stdout.WriteString(wrap(base85Encode(data))) + + frag.RawData = data + os.Stdout.WriteString(frag.String()) default: log.Fatalf("unknown mode: %s", mode) } } + +func decodeHexInput(input []byte) ([]byte, error) { + var err error + var data []byte + for line := range bytes.Lines(input) { + line := bytes.Map(dropSpaces, line) + if len(line) == 0 || line[0] == '#' { + continue + } + data, err = hex.AppendDecode(data, line) + if err != nil { + return data, err + } + } + return data, nil +} + +func dropSpaces(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + return r +} diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error_dst_size_limit.hex b/gitdiff/testdata/apply/bin_fragment_delta_error_dst_size_limit.hex new file mode 100644 index 0000000..692cc9f --- /dev/null +++ b/gitdiff/testdata/apply/bin_fragment_delta_error_dst_size_limit.hex @@ -0,0 +1,9 @@ +# source size - 130 bytes +82 01 + +# result size - 260 bytes +88 02 + +# 8x delta copy operations of 130 bytes at offset 0 +90 82 +90 82 diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error_dst_size_limit.patch b/gitdiff/testdata/apply/bin_fragment_delta_error_dst_size_limit.patch new file mode 100644 index 0000000..41256b7 --- /dev/null +++ b/gitdiff/testdata/apply/bin_fragment_delta_error_dst_size_limit.patch @@ -0,0 +1,5 @@ +diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error.src b/gitdiff/testdata/apply/bin_fragment_delta_error.src +GIT binary patch +delta 8 +Tc${it>|mPEG@%I)0096048a33 + diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error_dst_size.patch b/gitdiff/testdata/apply/bin_fragment_delta_error_dst_size_short.patch similarity index 100% rename from gitdiff/testdata/apply/bin_fragment_delta_error_dst_size.patch rename to gitdiff/testdata/apply/bin_fragment_delta_error_dst_size_short.patch diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error_limit_add.hex b/gitdiff/testdata/apply/bin_fragment_delta_error_limit_add.hex new file mode 100644 index 0000000..2613e12 --- /dev/null +++ b/gitdiff/testdata/apply/bin_fragment_delta_error_limit_add.hex @@ -0,0 +1,28 @@ +# source size - 130 bytes +82 01 + +# result size - claimed 1024 bytes, actually 1100 +80 08 + +# 10x delta copy operations of 100 bytes at offset 0 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 + +# 1x delta add operation of 64 bytes +40 +01 00 01 01 00 01 00 01 +01 00 01 01 00 01 00 01 +01 00 01 01 00 01 00 01 +01 00 01 01 00 01 00 01 +01 00 01 01 00 01 00 01 +01 00 01 01 00 01 00 01 +01 00 01 01 00 01 00 01 +01 00 01 01 00 01 00 01 diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error_limit_add.patch b/gitdiff/testdata/apply/bin_fragment_delta_error_limit_add.patch new file mode 100644 index 0000000..e9cd087 --- /dev/null +++ b/gitdiff/testdata/apply/bin_fragment_delta_error_limit_add.patch @@ -0,0 +1,5 @@ +diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error.src b/gitdiff/testdata/apply/bin_fragment_delta_error.src +GIT binary patch +delta 89 +cc${itY~YxXf(acM85kKEAOu-7AOHaW|3VN7`~Uy| + diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error_limit_copy.hex b/gitdiff/testdata/apply/bin_fragment_delta_error_limit_copy.hex new file mode 100644 index 0000000..df1db6c --- /dev/null +++ b/gitdiff/testdata/apply/bin_fragment_delta_error_limit_copy.hex @@ -0,0 +1,18 @@ +# source size - 130 bytes +82 01 + +# result size - claimed 1024 bytes, actually 1100 +80 08 + +# 11x delta copy operations of 100 bytes at offset 0 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 +90 64 diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error_limit_copy.patch b/gitdiff/testdata/apply/bin_fragment_delta_error_limit_copy.patch new file mode 100644 index 0000000..bf2029c --- /dev/null +++ b/gitdiff/testdata/apply/bin_fragment_delta_error_limit_copy.patch @@ -0,0 +1,5 @@ +diff --git a/gitdiff/testdata/apply/bin_fragment_delta_error.src b/gitdiff/testdata/apply/bin_fragment_delta_error.src +GIT binary patch +delta 26 +Tc${itY~YxXf(-%!00960lcNiW + diff --git a/gitdiff/testdata/apply/bin_fragment_literal_error_limit.patch b/gitdiff/testdata/apply/bin_fragment_literal_error_limit.patch new file mode 100644 index 0000000..2058854 --- /dev/null +++ b/gitdiff/testdata/apply/bin_fragment_literal_error_limit.patch @@ -0,0 +1,13 @@ +diff --git a/gitdiff/testdata/apply/bin_fragment_literal_create.src b/gitdiff/testdata/apply/bin_fragment_literal_create.src +GIT binary patch +literal 256 +zc$@$L0ssE~1Zc?U@Z18zeVql2Z+OX2?`MB@-)6s=B?NrLd25lQZhkW#HnOm(w0tfJ +z>=*tf;y#agZO%Q(CSMaN?#=h&z+H8}v%mK;&V%{q- +z#~YUBr(*NYPyXm6eE5@%8bmnOcbJI{!cu;m +z_n39~?nd`D4N>H9^iLk%q@5IeH?n=m$0x6i&aQ(_}%~74=_ZSSHP8 +zXz-tr>k<7|C`CWed_ms*mu6QUUkhV1KYx&5e-E7vsRc`8H*SO-xiFOB8d-UVD4$Z{ +LN|d+(00960;hlnx + +literal 0 +HcmV?d00001 +