From 8ce0daadc7b50d1c8015b7f984ae104d97080d19 Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Fri, 28 Nov 2025 19:08:29 +0300 Subject: [PATCH 1/2] proto: Regenerate code with newer NeoFS API version From https://github.com/nspcc-dev/neofs-api/commit/d88c4ff808ce4ee624c6597a76883c34d3ce9dae. Brought doc changes only. Signed-off-by: Leonard Lyubich --- proto/container/types.pb.go | 20 ++++++++++++++++++++ proto/netmap/types.pb.go | 3 ++- proto/object/types.pb.go | 4 ++++ 3 files changed, 26 insertions(+), 1 deletion(-) diff --git a/proto/container/types.pb.go b/proto/container/types.pb.go index ab9c7e9d..22a66a19 100644 --- a/proto/container/types.pb.go +++ b/proto/container/types.pb.go @@ -166,8 +166,28 @@ func (x *Container) GetPlacementPolicy() *netmap.PlacementPolicy { // // - Name \ // Human-friendly name +// // - Timestamp \ // User-defined local time of container creation in Unix Timestamp format +// +// - CORS \ +// It is used to configure your container to allow cross-origin requests (CORS). The rules are represented as a JSON +// array of objects with the following fields: +// 1. "AllowedMethods": In this element, you specify allowed HTTP methods: GET, PUT, POST, DELETE, HEAD. +// 2. "AllowedOrigins": In this element, you specify the origins that you want to allow cross-domain requests from, +// for example, http://www.example.com. The origin string can contain only one "*" wildcard character, +// such as http://*.example.com. You can optionally specify "*" as the origin to enable all the origins to send +// cross-origin requests. You can also specify https to enable only secure origins. +// 3. "AllowedHeaders": The element specifies which headers are allowed in a preflight request through the +// "Access-Control-Request-Headers" request header. Each AllowedHeaders string in your configuration can contain +// at most one "*" wildcard character. For example, x-app-*. +// 4. "ExposeHeaders": Each ExposeHeader element identifies a header in the response that you want customers +// to be able to access from their applications (for example, from a JavaScript XMLHttpRequest object). +// 5. "MaxAgeSeconds": The element specifies the time in seconds that your browser can cache the response for a +// preflight request as identified by the resource, the HTTP method, and the origin. +// +// The CORS schema is based on Amazon S3 CORS (https://docs.aws.amazon.com/AmazonS3/latest/userguide/cors.html) +// configuration. type Container_Attribute struct { state protoimpl.MessageState `protogen:"open.v1"` // Attribute name key diff --git a/proto/netmap/types.pb.go b/proto/netmap/types.pb.go index 365bcc78..391014db 100644 --- a/proto/netmap/types.pb.go +++ b/proto/netmap/types.pb.go @@ -796,7 +796,8 @@ func (x *NetworkInfo) GetNetworkConfig() *NetworkConfig { // contain the original payload. If its length is not divisible by // `data_part_num`, the last part is aligned with zero bytes. Both // `data_part_num` and `parity_part_num` MUST NOT be zero or exceed 64, -// including in total. +// including in total. Hashes from all parts are written in the +// `__NEOFS__EC_PART_HASHES` attribute of the original object's header. // // For each payload part, a part object is created. Original object's ID, // signature and header is written in `header.split.parent`, diff --git a/proto/object/types.pb.go b/proto/object/types.pb.go index 35eea334..b95bf923 100644 --- a/proto/object/types.pb.go +++ b/proto/object/types.pb.go @@ -735,6 +735,10 @@ func (x *SplitInfo) GetFirstPart() *refs.ObjectID { // - __NEOFS__EC_PART_IDX \ // Index in the EC parts into which the parent object is divided according // to `__NEOFS__EC_RULE_IDX` EC rule. Base-10 integer. +// - __NEOFS__EC_PART_HASHES \ +// Ordered list of payload hashes of EC parts into which this object is +// divided. Hash function is SHA-256. Items are comma-separated, each item +// is hex-encoded. // // And some well-known attributes used by applications only: // From 4205c885619aadc162625a00a264bc0b6c17f89b Mon Sep 17 00:00:00 2001 From: Leonard Lyubich Date: Fri, 28 Nov 2025 19:28:43 +0300 Subject: [PATCH 2/2] container: Support locks From https://github.com/nspcc-dev/neofs-api/commit/69784132f23500f1e546b86a10801a3b77892b71. Needed for https://github.com/nspcc-dev/neofs-api/issues/338, https://github.com/nspcc-dev/neofs-contract/issues/497. Signed-off-by: Leonard Lyubich --- client/client_test.go | 2 +- client/container.go | 1 + client/container_test.go | 21 ++++++++++++ client/status/container.go | 49 ++++++++++++++++++++++++++++ client/status/container_test.go | 16 ++++++++++ client/status/errors_test.go | 5 ++- client/status/v2.go | 3 ++ client/status/v2_test.go | 11 +++++++ container/container.go | 31 +++++++++++++++++- container/container_test.go | 51 ++++++++++++++++++++++++++++++ proto/container/service_grpc.pb.go | 8 +++-- proto/container/types.pb.go | 2 ++ proto/status/codes.go | 1 + proto/status/types.pb.go | 9 ++++-- 14 files changed, 203 insertions(+), 7 deletions(-) create mode 100644 client/status/container_test.go diff --git a/client/client_test.go b/client/client_test.go index 24b4fa5f..ccf97104 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -1074,7 +1074,7 @@ func testStatusResponses[SRV interface { 1023, 1036, 2055, - 3074, + 3075, 4098, } { t.Run("unrecognized_"+strconv.FormatUint(uint64(code), 10), func(t *testing.T) { diff --git a/client/container.go b/client/container.go index 66e2b0d5..495d32f6 100644 --- a/client/container.go +++ b/client/container.go @@ -388,6 +388,7 @@ func (x *PrmContainerDelete) AttachSignature(sig neofscrypto.Signature) { // Reflects all internal errors in second return value (transport problems, response processing, etc.). // Return errors: // - [ErrMissingSigner] +// - [apistatus.ErrContainerLocked] func (c *Client) ContainerDelete(ctx context.Context, id cid.ID, signer neofscrypto.Signer, prm PrmContainerDelete) error { var err error if c.prm.statisticCallback != nil { diff --git a/client/container_test.go b/client/container_test.go index 8690e57a..68d86d25 100644 --- a/client/container_test.go +++ b/client/container_test.go @@ -9,6 +9,7 @@ import ( "testing" "time" + apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" "github.com/nspcc-dev/neofs-sdk-go/container" cid "github.com/nspcc-dev/neofs-sdk-go/container/id" cidtest "github.com/nspcc-dev/neofs-sdk-go/container/id/test" @@ -24,6 +25,7 @@ import ( protonetmap "github.com/nspcc-dev/neofs-sdk-go/proto/netmap" protorefs "github.com/nspcc-dev/neofs-sdk-go/proto/refs" protosession "github.com/nspcc-dev/neofs-sdk-go/proto/session" + protostatus "github.com/nspcc-dev/neofs-sdk-go/proto/status" "github.com/nspcc-dev/neofs-sdk-go/session" sessiontest "github.com/nspcc-dev/neofs-sdk-go/session/test" "github.com/nspcc-dev/neofs-sdk-go/stat" @@ -1409,6 +1411,25 @@ func TestClient_ContainerDelete(t *testing.T) { } }) t.Run("statuses", func(t *testing.T) { + t.Run("locked", func(t *testing.T) { + srv := newTestDeleteContainerServer() + c := newTestContainerClient(t, srv) + + st := protostatus.Status{Code: 3074} + + srv.respondWithStatus(&st) + + err := c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + require.ErrorIs(t, err, apistatus.ErrContainerLocked) + require.EqualError(t, err, "status: code = 3074 message = container is locked") + + st.Message = "some lock context" + + err = c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) + require.ErrorIs(t, err, apistatus.ErrContainerLocked) + require.EqualError(t, err, "status: code = 3074 message = some lock context") + }) + testStatusResponses(t, newTestDeleteContainerServer, newTestContainerClient, func(c *Client) error { return c.ContainerDelete(ctx, anyID, anyValidSigner, anyValidOpts) }) diff --git a/client/status/container.go b/client/status/container.go index eb31a660..4002ef33 100644 --- a/client/status/container.go +++ b/client/status/container.go @@ -13,6 +13,9 @@ var ( // ErrContainerNotFound is an instance of ContainerNotFound error status. It's expected to be used for [errors.Is] // and MUST NOT be changed. ErrContainerNotFound ContainerNotFound + // ErrContainerLocked is an instance of ContainerLocked error status. It's expected to be used for [errors.Is] + // and MUST NOT be changed. + ErrContainerLocked ContainerLocked ) // ContainerNotFound describes status of the failure because of the missing container. @@ -95,3 +98,49 @@ func (x EACLNotFound) protoMessage() *protostatus.Status { } return &protostatus.Status{Code: protostatus.EACLNotFound, Message: x.msg, Details: x.dts} } + +// ContainerLocked describes status of the failure because of the locked container. +type ContainerLocked struct { + msg string + dts []*protostatus.Status_Detail +} + +// NewContainerLocked constructs ContainerLocked with given message. +func NewContainerLocked(msg string) ContainerLocked { + return ContainerLocked{msg: msg} +} + +const defaultContainerLockedMsg = "container is locked" + +// Error implements built-in [error] interface. +func (x ContainerLocked) Error() string { + if x.msg == "" { + x.msg = defaultContainerLockedMsg + } + + return errMessageStatus(protostatus.ContainerLocked, x.msg) +} + +// Is implements interface for correct checking current error type with [errors.Is]. +func (x ContainerLocked) Is(target error) bool { + switch target.(type) { + default: + return errors.Is(Error, target) + case ContainerLocked, *ContainerLocked: + return true + } +} + +// implements local interface defined in [ToError] func. +func (x *ContainerLocked) fromProtoMessage(st *protostatus.Status) { + x.msg = st.Message + x.dts = st.Details +} + +// implements local interface defined in [FromError] func. +func (x ContainerLocked) protoMessage() *protostatus.Status { + if x.msg == "" { + x.msg = defaultContainerLockedMsg + } + return &protostatus.Status{Code: protostatus.ContainerLocked, Message: x.msg, Details: x.dts} +} diff --git a/client/status/container_test.go b/client/status/container_test.go new file mode 100644 index 00000000..c3aff682 --- /dev/null +++ b/client/status/container_test.go @@ -0,0 +1,16 @@ +package apistatus_test + +import ( + "testing" + + apistatus "github.com/nspcc-dev/neofs-sdk-go/client/status" + "github.com/stretchr/testify/require" +) + +func TestNewContainerLocked(t *testing.T) { + var e apistatus.ContainerLocked + require.EqualError(t, e, "status: code = 3074 message = container is locked") + + e = apistatus.NewContainerLocked("some message") + require.EqualError(t, e, "status: code = 3074 message = some message") +} diff --git a/client/status/errors_test.go b/client/status/errors_test.go index bfd4bcec..dadb64b6 100644 --- a/client/status/errors_test.go +++ b/client/status/errors_test.go @@ -76,7 +76,10 @@ func TestErrors(t *testing.T) { errs: []error{EACLNotFound{}, new(EACLNotFound)}, errVariable: ErrEACLNotFound, }, - + { + errs: []error{ContainerLocked{}, new(ContainerLocked)}, + errVariable: ErrContainerLocked, + }, { errs: []error{SessionTokenExpired{}, new(SessionTokenExpired)}, errVariable: ErrSessionTokenExpired, diff --git a/client/status/v2.go b/client/status/v2.go index e475c945..54239861 100644 --- a/client/status/v2.go +++ b/client/status/v2.go @@ -33,6 +33,7 @@ import ( // Container failures: // - [protostatus.ContainerNotFound]: *[ContainerNotFound]; // - [protostatus.EACLNotFound]: *[EACLNotFound]; +// - [protostatus.ContainerLocked]: *[ContainerLocked]; // // Session failures: // - [protostatus.SessionTokenNotFound]: *[SessionTokenNotFound]; @@ -84,6 +85,8 @@ func ToError(st *protostatus.Status) error { decoder = new(ContainerNotFound) case protostatus.EACLNotFound: decoder = new(EACLNotFound) + case protostatus.ContainerLocked: + decoder = new(ContainerLocked) case protostatus.SessionTokenNotFound: decoder = new(SessionTokenNotFound) case protostatus.SessionTokenExpired: diff --git a/client/status/v2_test.go b/client/status/v2_test.go index ce9b8e2e..855cac29 100644 --- a/client/status/v2_test.go +++ b/client/status/v2_test.go @@ -203,6 +203,17 @@ func TestToError(t *testing.T) { return errors.As(err, &target) }, }, + { + new: func() error { + return new(apistatus.ContainerLocked) + }, + code: 3074, + compatibleErrs: []error{apistatus.ErrContainerLocked, apistatus.ContainerLocked{}, &apistatus.ContainerLocked{}, apistatus.Error}, + checkAsErr: func(err error) bool { + var target *apistatus.ContainerLocked + return errors.As(err, &target) + }, + }, { new: func() error { return new(apistatus.SessionTokenNotFound) diff --git a/container/container.go b/container/container.go index f8e1d6da..917f0393 100644 --- a/container/container.go +++ b/container/container.go @@ -26,6 +26,7 @@ const ( sysAttrDisableHomohash = sysAttrPrefix + "DISABLE_HOMOMORPHIC_HASHING" sysAttrDomainName = sysAttrPrefix + "NAME" sysAttrDomainZone = sysAttrPrefix + "ZONE" + sysAttrLockUntil = sysAttrPrefix + "LOCK_UNTIL" ) // Container represents descriptor of the NeoFS container. Container logically @@ -168,7 +169,7 @@ func (x *Container) fromProtoMessage(m *protocontainer.Container, checkFieldPres } switch key { - case attributeTimestamp: + case attributeTimestamp, sysAttrLockUntil: _, err = strconv.ParseInt(val, 10, 64) } @@ -542,3 +543,31 @@ func (x Container) Version() version.Version { } return version.Version{} } + +// SetLockUntil sets attribute with removal lock timestamp in Unix Timestamp +// format. +func (x *Container) SetLockUntil(until time.Time) { + x.SetAttribute(sysAttrLockUntil, strconv.FormatInt(until.Unix(), 10)) +} + +// GetLockUntil looks up for attribute with removal lock timestamp in Unix +// Timestamp format. If attribute is missing, GetLockUntil returns both zero. +// Otherwise, GetLockUntil parses the value. If parsing fails, GetLockUntil +// returns an error containing the value. +func (x Container) GetLockUntil() (time.Time, error) { + attr := x.Attribute(sysAttrLockUntil) + if attr == "" { + return time.Time{}, nil + } + + n, err := strconv.ParseInt(attr, 10, 64) + if err != nil { + var ne *strconv.NumError + if !errors.As(err, &ne) { + panic(fmt.Sprintf("unexpected strconv.ParseInt error type %T", err)) + } + return time.Time{}, fmt.Errorf("parse %q: %w", ne.Num, ne.Err) + } + + return time.Unix(n, 0), nil +} diff --git a/container/container_test.go b/container/container_test.go index 974e38c6..62415613 100644 --- a/container/container_test.go +++ b/container/container_test.go @@ -1003,3 +1003,54 @@ func TestContainer_VerifySignature(t *testing.T) { require.False(t, validContainer.VerifySignature(sig), i) } } + +func TestContainer_SetLockUntil(t *testing.T) { + var cnr container.Container + + got, err := cnr.GetLockUntil() + require.NoError(t, err) + require.Zero(t, got) + + until := time.Unix(time.Now().Unix(), 0) + + cnr.SetLockUntil(until) + + got, err = cnr.GetLockUntil() + require.NoError(t, err) + require.True(t, got.Equal(until)) + + until = until.Add(5 * time.Second) + + cnr.SetLockUntil(until) + + got, err = cnr.GetLockUntil() + require.NoError(t, err) + require.True(t, got.Equal(until)) +} + +func TestContainer_GetLockUntil(t *testing.T) { + var cnr container.Container + + got, err := cnr.GetLockUntil() + require.NoError(t, err) + require.Zero(t, got) + + cnr.SetAttribute("__NEOFS__LOCK_UNTIL", "foo") + _, err = cnr.GetLockUntil() + require.EqualError(t, err, `parse "foo": invalid syntax`) + + for _, tc := range []struct { + s string + n int64 + }{ + {s: "1234567890", n: 1234567890}, + {s: "0", n: 0}, + {s: "-42", n: -42}, + } { + cnr.SetAttribute("__NEOFS__LOCK_UNTIL", tc.s) + + got, err = cnr.GetLockUntil() + require.NoError(t, err) + require.True(t, got.Equal(time.Unix(tc.n, 0))) + } +} diff --git a/proto/container/service_grpc.pb.go b/proto/container/service_grpc.pb.go index 778c067a..a906f2cc 100644 --- a/proto/container/service_grpc.pb.go +++ b/proto/container/service_grpc.pb.go @@ -58,7 +58,9 @@ type ContainerServiceClient interface { // Statuses: // - **OK** (0, SECTION_SUCCESS): \ // request to remove the container has been sent to FS chain; - // - Common failures (SECTION_FAILURE_COMMON). + // - Common failures (SECTION_FAILURE_COMMON); + // - **CONTAINER_LOCKED** (3074, SECTION_CONTAINER): \ + // deleting a locked container is prohibited. Delete(ctx context.Context, in *DeleteRequest, opts ...grpc.CallOption) (*DeleteResponse, error) // Returns container structure from `Container` smart contract storage. // @@ -217,7 +219,9 @@ type ContainerServiceServer interface { // Statuses: // - **OK** (0, SECTION_SUCCESS): \ // request to remove the container has been sent to FS chain; - // - Common failures (SECTION_FAILURE_COMMON). + // - Common failures (SECTION_FAILURE_COMMON); + // - **CONTAINER_LOCKED** (3074, SECTION_CONTAINER): \ + // deleting a locked container is prohibited. Delete(context.Context, *DeleteRequest) (*DeleteResponse, error) // Returns container structure from `Container` smart contract storage. // diff --git a/proto/container/types.pb.go b/proto/container/types.pb.go index 22a66a19..f3370acb 100644 --- a/proto/container/types.pb.go +++ b/proto/container/types.pb.go @@ -161,6 +161,8 @@ func (x *Container) GetPlacementPolicy() *netmap.PlacementPolicy { // information, it is not indexed and object presence/number of copies // is not proven after a successful object PUT operation; the behavior // is the same as it was before this attribute introduction +// - __NEOFS__LOCK_UNTIL \ +// Timestamp until which the container cannot be deleted in Unix Timestamp format // // And some well-known attributes used by applications only: // diff --git a/proto/status/codes.go b/proto/status/codes.go index 784f8649..4d63518d 100644 --- a/proto/status/codes.go +++ b/proto/status/codes.go @@ -19,6 +19,7 @@ const ( QuotaExceeded = 2054 ContainerNotFound = 3072 EACLNotFound = 3073 + ContainerLocked = 3074 SessionTokenNotFound = 4096 SessionTokenExpired = 4097 ) diff --git a/proto/status/types.pb.go b/proto/status/types.pb.go index db673d5d..e144485d 100644 --- a/proto/status/types.pb.go +++ b/proto/status/types.pb.go @@ -289,6 +289,8 @@ const ( Container_CONTAINER_NOT_FOUND Container = 0 // [**3073**] eACL table not found. Container_EACL_NOT_FOUND Container = 1 + // [**3074**] Operation rejected by the container lock. + Container_CONTAINER_LOCKED Container = 2 ) // Enum value maps for Container. @@ -296,10 +298,12 @@ var ( Container_name = map[int32]string{ 0: "CONTAINER_NOT_FOUND", 1: "EACL_NOT_FOUND", + 2: "CONTAINER_LOCKED", } Container_value = map[string]int32{ "CONTAINER_NOT_FOUND": 0, "EACL_NOT_FOUND": 1, + "CONTAINER_LOCKED": 2, } ) @@ -567,10 +571,11 @@ const file_proto_status_types_proto_rawDesc = "" + "\x17LOCK_NON_REGULAR_OBJECT\x10\x03\x12\x1a\n" + "\x16OBJECT_ALREADY_REMOVED\x10\x04\x12\x10\n" + "\fOUT_OF_RANGE\x10\x05\x12\x12\n" + - "\x0eQUOTA_EXCEEDED\x10\x06*8\n" + + "\x0eQUOTA_EXCEEDED\x10\x06*N\n" + "\tContainer\x12\x17\n" + "\x13CONTAINER_NOT_FOUND\x10\x00\x12\x12\n" + - "\x0eEACL_NOT_FOUND\x10\x01*1\n" + + "\x0eEACL_NOT_FOUND\x10\x01\x12\x14\n" + + "\x10CONTAINER_LOCKED\x10\x02*1\n" + "\aSession\x12\x13\n" + "\x0fTOKEN_NOT_FOUND\x10\x00\x12\x11\n" + "\rTOKEN_EXPIRED\x10\x01BMZ.github.com/nspcc-dev/neofs-sdk-go/proto/status\xaa\x02\x1aNeo.FileStorage.API.Statusb\x06proto3"