626 lines
16 KiB
Go
626 lines
16 KiB
Go
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: "starting from the same offset",
|
|
patches: []objectSDK.Patch{
|
|
{
|
|
PayloadPatch: &objectSDK.PayloadPatch{
|
|
Range: rangeWithOffestWithLength(8, 3),
|
|
Chunk: []byte("1"),
|
|
},
|
|
},
|
|
{
|
|
PayloadPatch: &objectSDK.PayloadPatch{
|
|
Range: rangeWithOffestWithLength(11, 0),
|
|
Chunk: []byte("2"),
|
|
},
|
|
},
|
|
{
|
|
PayloadPatch: &objectSDK.PayloadPatch{
|
|
Range: rangeWithOffestWithLength(11, 0),
|
|
Chunk: []byte("3"),
|
|
},
|
|
},
|
|
},
|
|
originalObjectPayload: []byte("abcdefghijklmnop"),
|
|
patchedPayload: []byte("abcdefgh123lmnop"),
|
|
},
|
|
{
|
|
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())
|
|
})
|
|
}
|
|
|
|
}
|