diff --git a/go.mod b/go.mod index a481fb2..faa6b6b 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,7 @@ module git.frostfs.info/TrueCloudLab/frostfs-sdk-go go 1.21 require ( - git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20240726072425-3dfa2f4fd65e + git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20240730145254-c27b978770a3 git.frostfs.info/TrueCloudLab/frostfs-contract v0.19.3-0.20240621131249-49e5270f673e git.frostfs.info/TrueCloudLab/hrw v1.2.1 git.frostfs.info/TrueCloudLab/tzhash v1.8.0 diff --git a/go.sum b/go.sum index a23b405..b84f659 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,5 @@ -git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20240726072425-3dfa2f4fd65e h1:gEWT+70E/RvGkxtSv+PlyUN2vtJVymhQa1mypvrXukM= -git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20240726072425-3dfa2f4fd65e/go.mod h1:OBDSr+DqV1z4VDouoX3YMleNc4DPBVBWTG3WDT2PK1o= +git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20240730145254-c27b978770a3 h1:BbtF/98HU0nBl4szdDYAV3XadNE5sJ92uSFmNePQmfA= +git.frostfs.info/TrueCloudLab/frostfs-api-go/v2 v2.16.1-0.20240730145254-c27b978770a3/go.mod h1:OBDSr+DqV1z4VDouoX3YMleNc4DPBVBWTG3WDT2PK1o= git.frostfs.info/TrueCloudLab/frostfs-contract v0.19.3-0.20240621131249-49e5270f673e h1:kcBqZBiFIUBATUqEuvVigtkJJWQ2Gug/eYXn967o3M4= git.frostfs.info/TrueCloudLab/frostfs-contract v0.19.3-0.20240621131249-49e5270f673e/go.mod h1:F/fe1OoIDKr5Bz99q4sriuHDuf3aZefZy9ZsCqEtgxc= git.frostfs.info/TrueCloudLab/frostfs-crypto v0.6.0 h1:FxqFDhQYYgpe41qsIHVOcdzSVCB8JNSfPG7Uk4r2oSk= diff --git a/object/patch.go b/object/patch.go new file mode 100644 index 0000000..1cc30fc --- /dev/null +++ b/object/patch.go @@ -0,0 +1,103 @@ +package object + +import ( + v2object "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/object" + "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/refs" + oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" +) + +// Patch is a patch that's applied for an object. +type Patch struct { + // The address of the object for which the patch is being applied. + Address oid.Address + + // The list of new attributes to set in the object's header. + NewAttributes []Attribute + + // If ReplaceAttributes flag is true, then the header's attributes are reset and + // filled with NewAttributes. Otherwise, the attributes are just merged. + ReplaceAttributes bool + + // Payload patch. If this field is not set, then it assumed such Patch patches only + // header (see NewAttributes, ReplaceAttributes). + PayloadPatch *PayloadPatch +} + +func (p *Patch) ToV2() *v2object.PatchRequestBody { + if p == nil { + return nil + } + + v2 := new(v2object.PatchRequestBody) + + addrV2 := new(refs.Address) + p.Address.WriteToV2(addrV2) + v2.SetAddress(addrV2) + + attrs := make([]v2object.Attribute, len(p.NewAttributes)) + for i := range p.NewAttributes { + attrs[i] = *p.NewAttributes[i].ToV2() + } + v2.SetNewAttributes(attrs) + v2.SetReplaceAttributes(p.ReplaceAttributes) + + v2.SetPatch(p.PayloadPatch.ToV2()) + + return v2 +} + +func (p *Patch) FromV2(patch *v2object.PatchRequestBody) { + if patch == nil { + return + } + + if addr := patch.GetAddress(); addr != nil { + _ = p.Address.ReadFromV2(*addr) + } + + newAttrs := patch.GetNewAttributes() + p.NewAttributes = make([]Attribute, len(newAttrs)) + for i := range newAttrs { + p.NewAttributes[i] = *NewAttributeFromV2(&newAttrs[i]) + } + + p.ReplaceAttributes = patch.GetReplaceAttributes() + + if v2patch := patch.GetPatch(); v2patch != nil { + p.PayloadPatch = new(PayloadPatch) + p.PayloadPatch.FromV2(v2patch) + } +} + +// Patch is a patch that's applied for an object's payload. +type PayloadPatch struct { + // Range of the patch application. + Range *Range + + // Chunk is the payload that replaces (or is appended to) the original object payload. + Chunk []byte +} + +func (p *PayloadPatch) ToV2() *v2object.PatchRequestBodyPatch { + if p == nil { + return nil + } + + v2 := new(v2object.PatchRequestBodyPatch) + + v2.Chunk = p.Chunk + v2.Range = p.Range.ToV2() + + return v2 +} + +func (p *PayloadPatch) FromV2(patch *v2object.PatchRequestBodyPatch) { + if patch == nil { + return + } + + p.Chunk = patch.Chunk + if patch.Range != nil { + p.Range = NewRangeFromV2(patch.Range) + } +} diff --git a/object/patch_test.go b/object/patch_test.go new file mode 100644 index 0000000..f66fd29 --- /dev/null +++ b/object/patch_test.go @@ -0,0 +1,161 @@ +package object + +import ( + "testing" + + v2object "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/object" + "git.frostfs.info/TrueCloudLab/frostfs-api-go/v2/refs" + oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" + oidtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id/test" + "github.com/stretchr/testify/require" +) + +func TestPatch(t *testing.T) { + t.Run("to v2", func(t *testing.T) { + t.Run("only attributes", func(t *testing.T) { + var p Patch + + var attr1, attr2 Attribute + attr1.SetKey("key1") + attr1.SetValue("val1") + attr2.SetKey("key2") + attr2.SetValue("val2") + + p.Address = oidtest.Address() + p.NewAttributes = []Attribute{attr1, attr2} + p.ReplaceAttributes = true + + v2patch := p.ToV2() + + addr := new(oid.Address) + require.NotNil(t, v2patch.GetAddress()) + addr.ReadFromV2(*v2patch.GetAddress()) + require.True(t, p.Address.Equals(*addr)) + + require.Equal(t, p.ReplaceAttributes, v2patch.GetReplaceAttributes()) + + require.Nil(t, v2patch.GetPatch()) + + require.Len(t, v2patch.GetNewAttributes(), 2) + require.Equal(t, attr1.Key(), v2patch.GetNewAttributes()[0].GetKey()) + require.Equal(t, attr2.Key(), v2patch.GetNewAttributes()[1].GetKey()) + require.Equal(t, attr1.Value(), v2patch.GetNewAttributes()[0].GetValue()) + require.Equal(t, attr2.Value(), v2patch.GetNewAttributes()[1].GetValue()) + }) + + t.Run("with payload patch", func(t *testing.T) { + var p Patch + + var attr1, attr2 Attribute + attr1.SetKey("key1") + attr1.SetValue("val1") + attr2.SetKey("key2") + attr2.SetValue("val2") + + p.Address = oidtest.Address() + p.NewAttributes = []Attribute{attr1, attr2} + p.ReplaceAttributes = true + + rng := &Range{} + rng.SetOffset(100) + rng.SetLength(10) + p.PayloadPatch = &PayloadPatch{ + Range: rng, + Chunk: []byte("payload_patch_chunk"), + } + + v2patch := p.ToV2() + + addr := new(oid.Address) + require.NotNil(t, v2patch.GetAddress()) + addr.ReadFromV2(*v2patch.GetAddress()) + require.True(t, p.Address.Equals(*addr)) + + require.Equal(t, p.ReplaceAttributes, v2patch.GetReplaceAttributes()) + + require.Len(t, v2patch.GetNewAttributes(), 2) + require.Equal(t, attr1.Key(), v2patch.GetNewAttributes()[0].GetKey()) + require.Equal(t, attr2.Key(), v2patch.GetNewAttributes()[1].GetKey()) + require.Equal(t, attr1.Value(), v2patch.GetNewAttributes()[0].GetValue()) + require.Equal(t, attr2.Value(), v2patch.GetNewAttributes()[1].GetValue()) + + require.NotNil(t, v2patch.GetPatch()) + require.NotNil(t, v2patch.GetPatch().Range) + require.Equal(t, uint64(100), v2patch.GetPatch().Range.GetOffset()) + require.Equal(t, uint64(10), v2patch.GetPatch().Range.GetLength()) + require.Equal(t, []byte("payload_patch_chunk"), v2patch.GetPatch().Chunk) + }) + + }) + + t.Run("from v2", func(t *testing.T) { + t.Run("only attributes", func(t *testing.T) { + v2patch := new(v2object.PatchRequestBody) + + address := oidtest.Address() + v2addr := new(refs.Address) + address.WriteToV2(v2addr) + v2patch.SetAddress(v2addr) + + var attr1, attr2 Attribute + attr1.SetKey("key1") + attr1.SetValue("val1") + attr2.SetKey("key2") + attr2.SetValue("val2") + + v2patch.SetNewAttributes([]v2object.Attribute{ + *attr1.ToV2(), *attr2.ToV2(), + }) + v2patch.SetReplaceAttributes(true) + + var p Patch + p.FromV2(v2patch) + + require.Equal(t, address, p.Address) + require.Equal(t, []Attribute{attr1, attr2}, p.NewAttributes) + require.Equal(t, true, p.ReplaceAttributes) + require.Nil(t, p.PayloadPatch) + }) + + t.Run("with payload patch", func(t *testing.T) { + v2patchReqBody := new(v2object.PatchRequestBody) + + address := oidtest.Address() + v2addr := new(refs.Address) + address.WriteToV2(v2addr) + v2patchReqBody.SetAddress(v2addr) + + var attr1, attr2 Attribute + attr1.SetKey("key1") + attr1.SetValue("val1") + attr2.SetKey("key2") + attr2.SetValue("val2") + + v2patchReqBody.SetNewAttributes([]v2object.Attribute{ + *attr1.ToV2(), *attr2.ToV2(), + }) + v2patchReqBody.SetReplaceAttributes(true) + + v2Rng := &v2object.Range{} + v2Rng.SetOffset(13) + v2Rng.SetLength(10) + v2Patch := &v2object.PatchRequestBodyPatch{ + Range: v2Rng, + Chunk: []byte("payload_patch_chunk"), + } + v2patchReqBody.SetPatch(v2Patch) + + var p Patch + p.FromV2(v2patchReqBody) + + require.Equal(t, address, p.Address) + require.Equal(t, []Attribute{attr1, attr2}, p.NewAttributes) + require.Equal(t, true, p.ReplaceAttributes) + require.NotNil(t, p.PayloadPatch) + require.NotNil(t, p.PayloadPatch.Range) + require.Equal(t, uint64(13), p.PayloadPatch.Range.GetOffset()) + require.Equal(t, uint64(10), p.PayloadPatch.Range.GetLength()) + require.Equal(t, []byte("payload_patch_chunk"), p.PayloadPatch.Chunk) + }) + }) +} diff --git a/object/patcher/patcher.go b/object/patcher/patcher.go new file mode 100644 index 0000000..ec448c9 --- /dev/null +++ b/object/patcher/patcher.go @@ -0,0 +1,242 @@ +package patcher + +import ( + "context" + "errors" + "fmt" + "io" + + objectSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/transformer" +) + +var ( + ErrOffsetExceedsSize = errors.New("patch offset exceeds object size") + ErrInvalidPatchOffsetOrder = errors.New("invalid patch offset order") + ErrPayloadPatchIsNil = errors.New("nil payload patch") + ErrAttrPatchAlreadyApplied = errors.New("attribute patch already applied") +) + +// PatchRes is the result of patch application. +type PatchRes struct { + AccessIdentifiers *transformer.AccessIdentifiers +} + +// PatchApplier is the interface that provides method to apply header and payload patches. +type PatchApplier interface { + // ApplyAttributesPatch applies the patch only for the object's attributes. + // + // ApplyAttributesPatch can't be invoked few times, otherwise it returns `ErrAttrPatchAlreadyApplied` error. + // + // The call is idempotent for the original header if it's invoked with empty `newAttrs` and + // `replaceAttrs = false`. + ApplyAttributesPatch(ctx context.Context, newAttrs []objectSDK.Attribute, replaceAttrs bool) error + + // ApplyPayloadPatch applies the patch for the object's payload. + // + // ApplyPayloadPatch returns `ErrPayloadPatchIsNil` error if patch is nil. + ApplyPayloadPatch(ctx context.Context, payloadPatch *objectSDK.PayloadPatch) error + + // Close closes PatchApplier when the patch stream is over. + Close(context.Context) (PatchRes, error) +} + +// RangeProvider is the interface that provides a method to get original object payload +// by a given range. +type RangeProvider interface { + // GetRange reads an original object payload by the given range. + // The method returns io.Reader over the data range only. This means if the data is read out, + // then GetRange has to be invoked to provide reader over the next range. + GetRange(ctx context.Context, rng *objectSDK.Range) io.Reader +} + +type patcher struct { + rangeProvider RangeProvider + + objectWriter transformer.ChunkedObjectWriter + + currOffset uint64 + + originalPayloadSize uint64 + + hdr *objectSDK.Object + + attrPatchAlreadyApplied bool + + readerBuffSize int +} + +const ( + DefaultReaderBufferSize = 64 * 1024 +) + +// Params is parameters to initialize patcher. +type Params struct { + // Original object header. + Header *objectSDK.Object + + // Range provider. + RangeProvider RangeProvider + + // ObjectWriter is the writer that writes the patched object. + ObjectWriter transformer.ChunkedObjectWriter + + // The size of the buffer used by the original payload range reader. + // If it's set to <=0, then `DefaultReaderBufferSize` is used. + ReaderBufferSize int +} + +func New(prm Params) PatchApplier { + readerBufferSize := prm.ReaderBufferSize + if readerBufferSize <= 0 { + readerBufferSize = DefaultReaderBufferSize + } + + return &patcher{ + rangeProvider: prm.RangeProvider, + + objectWriter: prm.ObjectWriter, + + hdr: prm.Header, + + originalPayloadSize: prm.Header.PayloadSize(), + + readerBuffSize: readerBufferSize, + } +} + +func (p *patcher) ApplyAttributesPatch(ctx context.Context, newAttrs []objectSDK.Attribute, replaceAttrs bool) error { + defer func() { + p.attrPatchAlreadyApplied = true + }() + + if p.attrPatchAlreadyApplied { + return ErrAttrPatchAlreadyApplied + } + + if replaceAttrs { + p.hdr.SetAttributes(newAttrs...) + } else if len(newAttrs) > 0 { + mergedAttrs := mergeAttributes(newAttrs, p.hdr.Attributes()) + p.hdr.SetAttributes(mergedAttrs...) + } + + if err := p.objectWriter.WriteHeader(ctx, p.hdr); err != nil { + return fmt.Errorf("writer header: %w", err) + } + return nil +} + +func (p *patcher) ApplyPayloadPatch(ctx context.Context, payloadPatch *objectSDK.PayloadPatch) error { + if payloadPatch == nil { + return ErrPayloadPatchIsNil + } + + if payloadPatch.Range.GetOffset() < p.currOffset { + return fmt.Errorf("%w: current = %d, previous = %d", ErrInvalidPatchOffsetOrder, payloadPatch.Range.GetOffset(), p.currOffset) + } + + if payloadPatch.Range.GetOffset() > p.originalPayloadSize { + return fmt.Errorf("%w: offset = %d, object size = %d", ErrOffsetExceedsSize, payloadPatch.Range.GetOffset(), p.originalPayloadSize) + } + + var err error + if p.currOffset, err = p.applyPatch(ctx, payloadPatch, p.currOffset); err != nil { + return fmt.Errorf("apply patch: %w", err) + } + + return nil +} + +func (p *patcher) Close(ctx context.Context) (PatchRes, error) { + rng := new(objectSDK.Range) + rng.SetOffset(p.currOffset) + rng.SetLength(p.originalPayloadSize - p.currOffset) + + // copy remaining originial payload + if err := p.copyRange(ctx, rng); err != nil { + return PatchRes{}, fmt.Errorf("copy payload: %w", err) + } + + aid, err := p.objectWriter.Close(ctx) + if err != nil { + return PatchRes{}, fmt.Errorf("close object writer: %w", err) + } + + return PatchRes{ + AccessIdentifiers: aid, + }, nil +} + +func (p *patcher) copyRange(ctx context.Context, rng *objectSDK.Range) error { + rdr := p.rangeProvider.GetRange(ctx, rng) + for { + buffOrigPayload := make([]byte, p.readerBuffSize) + n, readErr := rdr.Read(buffOrigPayload) + if readErr != nil { + if readErr != io.EOF { + return fmt.Errorf("read: %w", readErr) + } + } + _, wrErr := p.objectWriter.Write(ctx, buffOrigPayload[:n]) + if wrErr != nil { + return fmt.Errorf("write: %w", wrErr) + } + if readErr == io.EOF { + break + } + } + return nil +} + +func (p *patcher) applyPatch(ctx context.Context, payloadPatch *objectSDK.PayloadPatch, offset uint64) (newOffset uint64, err error) { + // write the original payload chunk before the start of the patch + if payloadPatch.Range.GetOffset() > offset { + rng := new(objectSDK.Range) + rng.SetOffset(offset) + rng.SetLength(payloadPatch.Range.GetOffset() - offset) + + if err = p.copyRange(ctx, rng); err != nil { + err = fmt.Errorf("copy payload: %w", err) + return + } + + newOffset = payloadPatch.Range.GetOffset() + } + + // apply patch + if _, err = p.objectWriter.Write(ctx, payloadPatch.Chunk); err != nil { + return + } + + if payloadPatch.Range.GetLength() > 0 { + newOffset += payloadPatch.Range.GetLength() + } + + return +} + +func mergeAttributes(newAttrs, oldAttrs []objectSDK.Attribute) []objectSDK.Attribute { + attrMap := make(map[string]string, len(newAttrs)) + + for _, attr := range newAttrs { + attrMap[attr.Key()] = attr.Value() + } + + for i := range oldAttrs { + newVal, ok := attrMap[oldAttrs[i].Key()] + if !ok { + continue + } + oldAttrs[i].SetValue(newVal) + delete(attrMap, oldAttrs[i].Key()) + } + + for _, newAttr := range newAttrs { + if _, ok := attrMap[newAttr.Key()]; ok { + oldAttrs = append(oldAttrs, newAttr) + } + } + + return oldAttrs +} diff --git a/object/patcher/patcher_test.go b/object/patcher/patcher_test.go new file mode 100644 index 0000000..bf2402b --- /dev/null +++ b/object/patcher/patcher_test.go @@ -0,0 +1,601 @@ +package patcher + +import ( + "bytes" + "context" + "io" + "testing" + + objectSDK "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object" + oid "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id" + oidtest "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/id/test" + "git.frostfs.info/TrueCloudLab/frostfs-sdk-go/object/transformer" + "github.com/stretchr/testify/require" +) + +type mockPatchedObjectWriter struct { + obj *objectSDK.Object +} + +func (m *mockPatchedObjectWriter) Write(_ context.Context, chunk []byte) (int, error) { + res := append(m.obj.Payload(), chunk...) + + m.obj.SetPayload(res) + m.obj.SetPayloadSize(uint64(len(res))) + + return len(chunk), nil +} + +func (m *mockPatchedObjectWriter) WriteHeader(_ context.Context, hdr *objectSDK.Object) error { + m.obj.ToV2().SetHeader(hdr.ToV2().GetHeader()) + return nil +} + +func (m *mockPatchedObjectWriter) Close(context.Context) (*transformer.AccessIdentifiers, error) { + return &transformer.AccessIdentifiers{}, nil +} + +type mockRangeProvider struct { + originalObjectPayload []byte +} + +var _ RangeProvider = (*mockRangeProvider)(nil) + +func (m *mockRangeProvider) GetRange(_ context.Context, rng *objectSDK.Range) io.Reader { + offset := rng.GetOffset() + length := rng.GetLength() + + if length == 0 { + return bytes.NewReader(m.originalObjectPayload[offset:]) + } + return bytes.NewReader(m.originalObjectPayload[offset : offset+length]) +} + +func newTestObject() (*objectSDK.Object, oid.Address) { + obj := objectSDK.New() + + addr := oidtest.Address() + obj.SetContainerID(addr.Container()) + obj.SetID(addr.Object()) + + return obj, addr +} + +func rangeWithOffestWithLength(offset, length uint64) *objectSDK.Range { + rng := new(objectSDK.Range) + rng.SetOffset(offset) + rng.SetLength(length) + return rng +} + +func TestPatchRevert(t *testing.T) { + obj, _ := newTestObject() + + modifPatch := &objectSDK.Patch{ + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(0, 0), + + Chunk: []byte("inserted"), + }, + } + + originalObjectPayload := []byte("*******************") + + obj.SetPayload(originalObjectPayload) + obj.SetPayloadSize(uint64(len(originalObjectPayload))) + + exp := []byte("inserted*******************") + + rangeProvider := &mockRangeProvider{ + originalObjectPayload: originalObjectPayload, + } + + patchedObj, _ := newTestObject() + + wr := &mockPatchedObjectWriter{ + obj: patchedObj, + } + + prm := Params{ + Header: obj.CutPayload(), + + RangeProvider: rangeProvider, + + ObjectWriter: wr, + } + + patcher := New(prm) + + err := patcher.ApplyAttributesPatch(context.Background(), modifPatch.NewAttributes, modifPatch.ReplaceAttributes) + require.NoError(t, err) + + err = patcher.ApplyPayloadPatch(context.Background(), modifPatch.PayloadPatch) + require.NoError(t, err) + + _, err = patcher.Close(context.Background()) + require.NoError(t, err) + + require.Equal(t, exp, patchedObj.Payload()) + + revertPatch := &objectSDK.Patch{ + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(0, uint64(len("inserted"))), + + Chunk: []byte{}, + }, + } + + rangeProvider = &mockRangeProvider{ + originalObjectPayload: exp, + } + + patchedPatchedObj, _ := newTestObject() + + wr = &mockPatchedObjectWriter{ + obj: patchedPatchedObj, + } + + prm = Params{ + Header: patchedObj.CutPayload(), + + RangeProvider: rangeProvider, + + ObjectWriter: wr, + } + + patcher = New(prm) + + err = patcher.ApplyAttributesPatch(context.Background(), revertPatch.NewAttributes, revertPatch.ReplaceAttributes) + require.NoError(t, err) + + err = patcher.ApplyPayloadPatch(context.Background(), revertPatch.PayloadPatch) + require.NoError(t, err) + + _, err = patcher.Close(context.Background()) + require.NoError(t, err) + + require.Equal(t, originalObjectPayload, patchedPatchedObj.Payload()) +} + +func TestPatchRepeatAttributePatch(t *testing.T) { + obj, _ := newTestObject() + + modifPatch := &objectSDK.Patch{} + + originalObjectPayload := []byte("*******************") + + obj.SetPayload(originalObjectPayload) + obj.SetPayloadSize(uint64(len(originalObjectPayload))) + + rangeProvider := &mockRangeProvider{ + originalObjectPayload: originalObjectPayload, + } + + patchedObj, _ := newTestObject() + + wr := &mockPatchedObjectWriter{ + obj: patchedObj, + } + + prm := Params{ + Header: obj.CutPayload(), + + RangeProvider: rangeProvider, + + ObjectWriter: wr, + } + + patcher := New(prm) + + err := patcher.ApplyAttributesPatch(context.Background(), modifPatch.NewAttributes, modifPatch.ReplaceAttributes) + require.NoError(t, err) + + err = patcher.ApplyAttributesPatch(context.Background(), modifPatch.NewAttributes, modifPatch.ReplaceAttributes) + require.ErrorIs(t, err, ErrAttrPatchAlreadyApplied) +} + +func TestPatchEmptyPayloadPatch(t *testing.T) { + obj, _ := newTestObject() + + modifPatch := &objectSDK.Patch{} + + originalObjectPayload := []byte("*******************") + + obj.SetPayload(originalObjectPayload) + obj.SetPayloadSize(uint64(len(originalObjectPayload))) + + rangeProvider := &mockRangeProvider{ + originalObjectPayload: originalObjectPayload, + } + + patchedObj, _ := newTestObject() + + wr := &mockPatchedObjectWriter{ + obj: patchedObj, + } + + prm := Params{ + Header: obj.CutPayload(), + + RangeProvider: rangeProvider, + + ObjectWriter: wr, + } + + patcher := New(prm) + + err := patcher.ApplyAttributesPatch(context.Background(), modifPatch.NewAttributes, modifPatch.ReplaceAttributes) + require.NoError(t, err) + + err = patcher.ApplyPayloadPatch(context.Background(), nil) + require.ErrorIs(t, err, ErrPayloadPatchIsNil) +} + +func newTestAttribute(key, val string) objectSDK.Attribute { + var attr objectSDK.Attribute + attr.SetKey(key) + attr.SetValue(val) + return attr +} + +func TestPatch(t *testing.T) { + for _, test := range []struct { + name string + patches []objectSDK.Patch + originalObjectPayload []byte + patchedPayload []byte + originalHeaderAttributes []objectSDK.Attribute + patchedHeaderAttributes []objectSDK.Attribute + expectedPayloadPatchErr error + }{ + { + name: "invalid offset", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(100, 0), + Chunk: []byte(""), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + expectedPayloadPatchErr: ErrOffsetExceedsSize, + }, + { + name: "invalid following patch offset", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(10, 0), + Chunk: []byte(""), + }, + }, + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(7, 0), + Chunk: []byte(""), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + expectedPayloadPatchErr: ErrInvalidPatchOffsetOrder, + }, + { + name: "only header patch", + patches: []objectSDK.Patch{ + { + NewAttributes: []objectSDK.Attribute{ + newTestAttribute("key1", "val2"), + newTestAttribute("key2", "val2"), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedHeaderAttributes: []objectSDK.Attribute{ + newTestAttribute("key1", "val2"), + newTestAttribute("key2", "val2"), + }, + }, + { + name: "header and payload", + patches: []objectSDK.Patch{ + { + NewAttributes: []objectSDK.Attribute{ + newTestAttribute("key1", "val2"), + newTestAttribute("key2", "val2"), + }, + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(0, 0), + Chunk: []byte("inserted at the beginning"), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("inserted at the beginning0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedHeaderAttributes: []objectSDK.Attribute{ + newTestAttribute("key1", "val2"), + newTestAttribute("key2", "val2"), + }, + }, + { + name: "header only merge attributes", + patches: []objectSDK.Patch{ + { + NewAttributes: []objectSDK.Attribute{ + newTestAttribute("key1", "val2"), + newTestAttribute("key2", "val2-incoming"), + }, + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(0, 0), + Chunk: []byte("inserted at the beginning"), + }, + }, + }, + originalHeaderAttributes: []objectSDK.Attribute{ + newTestAttribute("key2", "to be popped out"), + newTestAttribute("key3", "val3"), + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("inserted at the beginning0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedHeaderAttributes: []objectSDK.Attribute{ + newTestAttribute("key2", "val2-incoming"), + newTestAttribute("key3", "val3"), + newTestAttribute("key1", "val2"), + }, + }, + { + name: "header only then payload", + patches: []objectSDK.Patch{ + { + NewAttributes: []objectSDK.Attribute{ + newTestAttribute("key1", "val2"), + newTestAttribute("key2", "val2"), + }, + }, + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(0, 0), + Chunk: []byte("inserted at the beginning"), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("inserted at the beginning0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedHeaderAttributes: []objectSDK.Attribute{ + newTestAttribute("key1", "val2"), + newTestAttribute("key2", "val2"), + }, + }, + { + name: "no effect", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(0, 0), + Chunk: []byte(""), + }, + }, + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(12, 0), + Chunk: []byte(""), + }, + }, + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(20, 0), + Chunk: []byte(""), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + }, + { + name: "insert prefix", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(0, 0), + Chunk: []byte("inserted at the beginning"), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("inserted at the beginning0123456789qwertyuiopasdfghjklzxcvbnm"), + }, + { + name: "insert in the middle", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(5, 0), + Chunk: []byte("inserted somewhere in the middle"), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("01234inserted somewhere in the middle56789qwertyuiopasdfghjklzxcvbnm"), + }, + { + name: "insert at the end", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(36, 0), + Chunk: []byte("inserted somewhere at the end"), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnminserted somewhere at the end"), + }, + { + name: "replace by range", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(0, 12), + Chunk: []byte("just replace"), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("just replaceertyuiopasdfghjklzxcvbnm"), + }, + { + name: "replace and insert some bytes", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(0, 11), + Chunk: []byte("replace and append in the middle"), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("replace and append in the middlewertyuiopasdfghjklzxcvbnm"), + }, + { + name: "replace and insert some bytes in the middle", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(5, 3), + Chunk: []byte("@@@@@"), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("01234@@@@@89qwertyuiopasdfghjklzxcvbnm"), + }, + { + name: "a few patches: prefix, suffix", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(0, 0), + Chunk: []byte("this_will_be_prefix"), + }, + }, + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(36, 0), + Chunk: []byte("this_will_be_suffix"), + }, + }, + }, + originalObjectPayload: []byte("0123456789qwertyuiopasdfghjklzxcvbnm"), + patchedPayload: []byte("this_will_be_prefix0123456789qwertyuiopasdfghjklzxcvbnmthis_will_be_suffix"), + }, + { + name: "a few patches: replace and insert some bytes", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(10, 3), + Chunk: []byte("aaaaa"), + }, + }, + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(16, 0), + Chunk: []byte("bbbbb"), + }, + }, + }, + originalObjectPayload: []byte("0123456789ABCDEF"), + patchedPayload: []byte("0123456789aaaaaDEFbbbbb"), + }, + { + name: "a few patches: various modifiactions", + patches: []objectSDK.Patch{ + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(4, 8), + Chunk: []byte("earliest"), + }, + }, + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(13, 0), + Chunk: []byte("known "), + }, + }, + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(35, 8), + Chunk: []byte("a small town"), + }, + }, + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(62, 6), + Chunk: []byte("tablet"), + }, + }, + { + PayloadPatch: &objectSDK.PayloadPatch{ + Range: rangeWithOffestWithLength(87, 0), + Chunk: []byte("Shar-Kali-Sharri"), + }, + }, + }, + originalObjectPayload: []byte("The ******** mention of Babylon as [insert] appears on a clay ****** from the reign of "), + patchedPayload: []byte("The earliest known mention of Babylon as a small town appears on a clay tablet from the reign of Shar-Kali-Sharri"), + }, + } { + t.Run(test.name, func(t *testing.T) { + rangeProvider := &mockRangeProvider{ + originalObjectPayload: test.originalObjectPayload, + } + + originalObject, _ := newTestObject() + originalObject.SetPayload(test.originalObjectPayload) + originalObject.SetPayloadSize(uint64(len(test.originalObjectPayload))) + originalObject.SetAttributes(test.originalHeaderAttributes...) + + patchedObject, _ := newTestObject() + + wr := &mockPatchedObjectWriter{ + obj: patchedObject, + } + + prm := Params{ + Header: originalObject.CutPayload(), + + RangeProvider: rangeProvider, + + ObjectWriter: wr, + } + + patcher := New(prm) + + for i, patch := range test.patches { + if i == 0 { + _ = patcher.ApplyAttributesPatch(context.Background(), patch.NewAttributes, patch.ReplaceAttributes) + } + + if patch.PayloadPatch == nil { + continue + } + + err := patcher.ApplyPayloadPatch(context.Background(), patch.PayloadPatch) + if err != nil && test.expectedPayloadPatchErr != nil { + require.ErrorIs(t, err, test.expectedPayloadPatchErr) + return + } + require.NoError(t, err) + } + + _, err := patcher.Close(context.Background()) + require.NoError(t, err) + require.Equal(t, test.patchedPayload, patchedObject.Payload()) + + patchedAttrs := append([]objectSDK.Attribute{}, test.patchedHeaderAttributes...) + require.Equal(t, patchedAttrs, patchedObject.Attributes()) + }) + } + +}