From f6818c1dfbf2d2d6bb3ae58043436d95a0f14063 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Fri, 19 Dec 2025 13:32:16 +0300 Subject: [PATCH] integrity: implement basic integrity storage Part of TNTP-4191 --- .golangci.yml | 1 + driver/etcd/integration_test.go | 23 +- integrity/builder.go | 194 ++++++ integrity/error.go | 13 + integrity/error_test.go | 1 - integrity/result.go | 12 + integrity/typed.go | 252 +++++++ integrity/typed_test.go | 1114 +++++++++++++++++++++++++++++++ integrity/utils.go | 3 - integrity/validator.go | 32 +- integrity/validator_test.go | 108 ++- internal/options/options.go | 19 + namer/namer.go | 15 +- storage.go | 70 +- 14 files changed, 1793 insertions(+), 64 deletions(-) create mode 100644 integrity/builder.go create mode 100644 integrity/result.go create mode 100644 integrity/typed.go create mode 100644 integrity/typed_test.go delete mode 100644 integrity/utils.go create mode 100644 internal/options/options.go diff --git a/.golangci.yml b/.golangci.yml index 6780a54..561b3b5 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -39,6 +39,7 @@ linters: varnamelen: ignore-names: - tt + - ok ignore-decls: - mc *minimock.Controller - t T diff --git a/driver/etcd/integration_test.go b/driver/etcd/integration_test.go index 30024e6..8b91675 100644 --- a/driver/etcd/integration_test.go +++ b/driver/etcd/integration_test.go @@ -78,7 +78,7 @@ func createTestDriver(t *testing.T) (*etcddriver.Driver, func()) { func testKey(t *testing.T, prefix string) []byte { t.Helper() - return []byte("/test/" + prefix + "/" + t.Name()) + return []byte("/" + t.Name() + "/" + prefix) } func testNestedKey(t *testing.T, prefix, suffix string) []byte { @@ -159,6 +159,27 @@ func TestEtcdDriver_Get(t *testing.T) { assert.Positive(t, retrievedKv.ModRevision, "ModRevision should be greater than 0") } +func TestEtcdDriver_GetPrefix(t *testing.T) { + ctx := context.Background() + + driver, cleanup := createTestDriver(t) + defer cleanup() + + key := testKey(t, "get") + value := []byte("get-test-value") + + putValue(ctx, t, driver, append(key, []byte("/123")...), value) + putValue(ctx, t, driver, append(key, []byte("/124")...), value) + + response, err := driver.Execute(ctx, nil, []operation.Operation{ + operation.Get(append(key, []byte("/")...)), + }, nil) + require.NoError(t, err, "Get operation failed") + assert.True(t, response.Succeeded, "Get operation should succeed") + require.Len(t, response.Results, 1, "Get operation should return one result") + require.Len(t, response.Results[0].Values, 2, "Get operation should return one value") +} + func TestEtcdDriver_Delete(t *testing.T) { ctx := context.Background() diff --git a/integrity/builder.go b/integrity/builder.go new file mode 100644 index 0000000..6683096 --- /dev/null +++ b/integrity/builder.go @@ -0,0 +1,194 @@ +// Package integrity provides integrity-protected storage operations. +// It includes generators, validators, and builders for creating typed storage +// with hash and signature verification. +package integrity + +import ( + "maps" + "slices" + + "github.com/tarantool/go-storage" + "github.com/tarantool/go-storage/crypto" + "github.com/tarantool/go-storage/hasher" + "github.com/tarantool/go-storage/marshaller" + "github.com/tarantool/go-storage/namer" +) + +type NamerConstructor func(prefix string, hashNames []string, sigNames []string) namer.Namer + +// TypedBuilder builds typed storage instances with integrity protection. +type TypedBuilder[T any] struct { + storage storage.Storage + hashers []hasher.Hasher + signers []crypto.Signer + verifiers []crypto.Verifier + marshaller marshaller.TypedYamlMarshaller[T] + + prefix string + namerFunc NamerConstructor +} + +// NewTypedBuilder creates a new TypedBuilder for the given storage instance. +func NewTypedBuilder[T any](storageInstance storage.Storage) TypedBuilder[T] { + return TypedBuilder[T]{ + storage: storageInstance, + hashers: []hasher.Hasher{}, + signers: []crypto.Signer{}, + verifiers: []crypto.Verifier{}, + marshaller: marshaller.NewTypedYamlMarshaller[T](), + + prefix: "/", + namerFunc: nil, + } +} + +func (s TypedBuilder[T]) copy() TypedBuilder[T] { + return TypedBuilder[T]{ + storage: s.storage, + hashers: slices.Clone(s.hashers), + signers: slices.Clone(s.signers), + verifiers: slices.Clone(s.verifiers), + marshaller: s.marshaller, + + prefix: s.prefix, + namerFunc: s.namerFunc, + } +} + +// WithHasher adds a hasher to the builder. +func (s TypedBuilder[T]) WithHasher(h hasher.Hasher) TypedBuilder[T] { + out := s.copy() + + out.hashers = append(out.hashers, h) + + return out +} + +// WithSignerVerifier adds a signer/verifier to the builder. +func (s TypedBuilder[T]) WithSignerVerifier(sv crypto.SignerVerifier) TypedBuilder[T] { + out := s.copy() + + out.signers = append(out.signers, sv) + out.verifiers = append(out.verifiers, sv) + + return out +} + +// WithSigner adds a signer to the builder. +func (s TypedBuilder[T]) WithSigner(signer crypto.Signer) TypedBuilder[T] { + out := s.copy() + + out.signers = append(out.signers, signer) + + return out +} + +// WithVerifier adds a verifier to the builder. +func (s TypedBuilder[T]) WithVerifier(verifier crypto.Verifier) TypedBuilder[T] { + out := s.copy() + + out.verifiers = append(out.verifiers, verifier) + + return out +} + +// WithMarshaller sets the marshaller for the builder. +func (s TypedBuilder[T]) WithMarshaller(marshaller marshaller.TypedYamlMarshaller[T]) TypedBuilder[T] { + out := s.copy() + + out.marshaller = marshaller + + return out +} + +// WithPrefix sets the key prefix for the builder. +func (s TypedBuilder[T]) WithPrefix(prefix string) TypedBuilder[T] { + out := s.copy() + + out.prefix = prefix + + return out +} + +// WithNamer sets the namer for the builder using a constructor function. +// The constructor function will be called during Build() with the current prefix. +func (s TypedBuilder[T]) WithNamer(namerFunc NamerConstructor) TypedBuilder[T] { + out := s.copy() + + out.namerFunc = namerFunc + + return out +} + +func (s TypedBuilder[T]) getHasherNames() []string { + names := make([]string, 0, len(s.hashers)) + for _, hasherInstance := range s.hashers { + names = append(names, hasherInstance.Name()) + } + + return names +} + +func (s TypedBuilder[T]) getSignerNames() []string { + names := make([]string, 0, len(s.signers)) + for _, signerInstance := range s.signers { + names = append(names, signerInstance.Name()) + } + + return names +} + +func (s TypedBuilder[T]) getVerifierNames() []string { + names := make([]string, 0, len(s.verifiers)) + for _, verifierInstance := range s.verifiers { + names = append(names, verifierInstance.Name()) + } + + return names +} + +func (s TypedBuilder[T]) getSignerVerifierNames() []string { + names := map[string]struct{}{} + + for _, name := range s.getSignerNames() { + names[name] = struct{}{} + } + + for _, name := range s.getVerifierNames() { + names[name] = struct{}{} + } + + return slices.Collect(maps.Keys(names)) +} + +// Build creates a new Typed storage instance with the configured options. +func (s TypedBuilder[T]) Build() *Typed[T] { + if s.namerFunc == nil { + s.namerFunc = namer.NewDefaultNamer + } + + hasherNames := s.getHasherNames() + + gen := NewGenerator( + s.namerFunc(s.prefix, hasherNames, s.getSignerNames()), + s.marshaller, + s.hashers, + s.signers, + ) + + val := NewValidator( + s.namerFunc(s.prefix, hasherNames, s.getVerifierNames()), + s.marshaller, + s.hashers, + s.verifiers, + ) + + namerInstance := s.namerFunc(s.prefix, hasherNames, s.getSignerVerifierNames()) + + return &Typed[T]{ + base: s.storage, + gen: gen, + val: val, + namer: namerInstance, + } +} diff --git a/integrity/error.go b/integrity/error.go index 37aeee9..6fdff4a 100644 --- a/integrity/error.go +++ b/integrity/error.go @@ -247,3 +247,16 @@ func (e *FailedToValidateAggregatedError) Finalize() error { return e } } + +// InvalidNameError represents an error when a name is invalid. +type InvalidNameError struct { + name string +} + +// Error returns a string representation of the invalid name error. +func (e InvalidNameError) Error() string { + return "invalid name: " + e.name +} + +// ErrInvalidName is a sentinel error for invalid names. +var ErrInvalidName = InvalidNameError{name: ""} diff --git a/integrity/error_test.go b/integrity/error_test.go index 2ca8d07..df0a5df 100644 --- a/integrity/error_test.go +++ b/integrity/error_test.go @@ -1,4 +1,3 @@ -// Package integrity provides integrity storage. package integrity //nolint:testpackage import ( diff --git a/integrity/result.go b/integrity/result.go new file mode 100644 index 0000000..c207374 --- /dev/null +++ b/integrity/result.go @@ -0,0 +1,12 @@ +package integrity + +import ( + "github.com/tarantool/go-option" +) + +// ValidatedResult represents a validated named value. +type ValidatedResult[T any] struct { + Name string + Value option.Generic[T] + Error error +} diff --git a/integrity/typed.go b/integrity/typed.go new file mode 100644 index 0000000..9db8c84 --- /dev/null +++ b/integrity/typed.go @@ -0,0 +1,252 @@ +package integrity + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/tarantool/go-storage" + "github.com/tarantool/go-storage/internal/options" + "github.com/tarantool/go-storage/kv" + "github.com/tarantool/go-storage/namer" + "github.com/tarantool/go-storage/operation" + "github.com/tarantool/go-storage/tx" + "github.com/tarantool/go-storage/watch" +) + +// Typed provides integrity-protected storage operations for typed values. +type Typed[T any] struct { + base storage.Storage + gen Generator[T] + val Validator[T] + namer namer.Namer +} + +func checkName(name string) bool { + return len(name) > 0 && !strings.Contains(name, "/") +} + +type getOptions struct { + ignoreVerificationError bool + ignoreMoreThanOneResult bool +} + +func IgnoreVerificationError() options.OptionCallback[getOptions] { + return func(opts *getOptions) { + opts.ignoreVerificationError = true + } +} + +func IgnoreMoreThanOneResult() options.OptionCallback[getOptions] { + return func(opts *getOptions) { + opts.ignoreMoreThanOneResult = true + } +} + +var ( + ErrNotFound = errors.New("not found") + ErrMoreThanOneResult = errors.New("more than one result was returned") +) + +func flattenResults(response tx.Response) []kv.KeyValue { + var kvs []kv.KeyValue + for _, r := range response.Results { + kvs = append(kvs, r.Values...) + } + + return kvs +} + +// Get retrieves and validates a single named value from storage. +func (t *Typed[T]) Get( + ctx context.Context, + name string, + vOpts ...options.OptionCallback[getOptions], +) (ValidatedResult[T], error) { + if !checkName(name) { + return ValidatedResult[T]{}, ErrInvalidName + } + + opts := options.ApplyOptions[getOptions](nil, vOpts) + + keys, err := t.namer.GenerateNames(name) + if err != nil { + return ValidatedResult[T]{}, fmt.Errorf("%w: failed to generate keys", err) + } + + ops := make([]operation.Operation, 0, len(keys)) + for _, key := range keys { + ops = append(ops, operation.Get([]byte(key.Build()))) + } + + response, err := t.base.Tx(ctx).Then(ops...).Commit() + if err != nil { + return ValidatedResult[T]{}, fmt.Errorf("%w: failed to execute", err) + } + + kvs := flattenResults(response) + + results, err := t.val.Validate(kvs) + switch { + case err != nil: + return ValidatedResult[T]{}, fmt.Errorf("%w: failed to validate", err) + case len(results) == 0: + return ValidatedResult[T]{}, ErrNotFound + case len(results) > 1 && !opts.ignoreMoreThanOneResult: + return ValidatedResult[T]{}, ErrMoreThanOneResult + } + + if results[0].Error != nil { + // results[0].Value.IsZero() means we've failed to decode results. + failedToDecodeResult := results[0].Value.IsZero() + + if opts.ignoreVerificationError && !failedToDecodeResult { + return results[0], nil + } + + return ValidatedResult[T]{}, results[0].Error + } + + if results[0].Value.IsZero() { + panic("unreachable") + } + + return results[0], nil +} + +// Put stores a named value with integrity protection. +func (t *Typed[T]) Put(ctx context.Context, name string, val T) error { + if !checkName(name) { + return ErrInvalidName + } + + kvs, err := t.gen.Generate(name, val) + if err != nil { + return fmt.Errorf("%w: failed to generate", err) + } + + ops := make([]operation.Operation, 0, len(kvs)) + for _, kv := range kvs { + ops = append(ops, operation.Put(kv.Key, kv.Value)) + } + + _, err = t.base.Tx(ctx).Then(ops...).Commit() + if err != nil { + return fmt.Errorf("%w: failed to execute", err) + } + + return nil +} + +// Delete removes a named value and its integrity data from storage. +func (t *Typed[T]) Delete(ctx context.Context, name string) error { + if !checkName(name) { + return ErrInvalidName + } + + keys, err := t.namer.GenerateNames(name) + if err != nil { + return fmt.Errorf("%w: failed to generate keys", err) + } + + ops := make([]operation.Operation, 0, len(keys)) + for _, key := range keys { + ops = append(ops, operation.Delete([]byte(key.Build()))) + } + + _, err = t.base.Tx(ctx).Then(ops...).Commit() + if err != nil { + return fmt.Errorf("%w: failed to execute", err) + } + + return nil +} + +// Range retrieves and validates all values under the given name prefix. +func (t *Typed[T]) Range( + ctx context.Context, + name string, + vOpts ...options.OptionCallback[getOptions], +) ([]ValidatedResult[T], error) { + prefix := t.namer.Prefix(name, true) + + opts := options.ApplyOptions[getOptions](nil, vOpts) + + // Get all keys under the base prefix. + ops := []operation.Operation{operation.Get([]byte(prefix))} + + response, err := t.base.Tx(ctx).Then(ops...).Commit() + if err != nil { + return nil, fmt.Errorf("%w: failed to execute range", err) + } + + kvs := flattenResults(response) + + results, err := t.val.Validate(kvs) + if err != nil { + return nil, err + } + + var out []ValidatedResult[T] + + for _, result := range results { + if result.Error == nil { + out = append(out, result) + continue + } + + // result.Value.IsZero() means we've failed to decode results, skipping. + failedToDecodeResult := result.Value.IsZero() + + if opts.ignoreVerificationError && !failedToDecodeResult { + out = append(out, result) + } + } + + return out, nil +} + +// Watch returns a channel for watching changes to values under the given name prefix. +func (t *Typed[T]) Watch(ctx context.Context, name string) <-chan watch.Event { + key := t.namer.Prefix(name, strings.HasSuffix(name, "/")) + + rawCh := t.base.Watch(ctx, []byte(key)) + filteredCh := make(chan watch.Event) + + go func() { + defer close(filteredCh) + + for { + select { + case <-ctx.Done(): + return + case event, ok := <-rawCh: + if !ok { + return + } + + // Parse the key from the event prefix. + key, err := t.namer.ParseKey(string(event.Prefix)) + if err != nil { + // Skip events for non-integrity keys. + continue + } + + // Filter by name prefix. + if !strings.HasPrefix(key.Name(), name) { + continue + } + + // Forward the event. + select { + case <-ctx.Done(): + return + case filteredCh <- event: + } + } + } + }() + + return filteredCh +} diff --git a/integrity/typed_test.go b/integrity/typed_test.go new file mode 100644 index 0000000..14f3db6 --- /dev/null +++ b/integrity/typed_test.go @@ -0,0 +1,1114 @@ +package integrity_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/go-storage" + "github.com/tarantool/go-storage/crypto" + "github.com/tarantool/go-storage/hasher" + "github.com/tarantool/go-storage/integrity" + "github.com/tarantool/go-storage/internal/mocks" + "github.com/tarantool/go-storage/kv" + "github.com/tarantool/go-storage/marshaller" + "github.com/tarantool/go-storage/namer" + "github.com/tarantool/go-storage/operation" + "github.com/tarantool/go-storage/tx" +) + +func TestTypedGet_InvalidName(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + ctx := context.Background() + _, err := typed.Get(ctx, "") + require.Error(t, err) + require.ErrorIs(t, err, integrity.ErrInvalidName) + + _, err = typed.Get(ctx, "name/with/slash") + require.Error(t, err) + assert.ErrorIs(t, err, integrity.ErrInvalidName) +} + +func TestTypedPut_InvalidName(t *testing.T) { + t.Parallel() + + ctx := context.Background() + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + err := typed.Put(ctx, "", SimpleStruct{Name: "test", Value: 42}) + require.Error(t, err) + require.ErrorIs(t, err, integrity.ErrInvalidName) + + err = typed.Put(ctx, "name/with/slash", SimpleStruct{Name: "test", Value: 42}) + require.Error(t, err) + assert.ErrorIs(t, err, integrity.ErrInvalidName) +} + +func TestTypedDelete_InvalidName(t *testing.T) { + t.Parallel() + + ctx := context.Background() + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + err := typed.Delete(ctx, "") + require.Error(t, err) + require.ErrorIs(t, err, integrity.ErrInvalidName) + + err = typed.Delete(ctx, "name/with/slash") + require.Error(t, err) + assert.ErrorIs(t, err, integrity.ErrInvalidName) +} + +func TestTypedGet_Success(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 1) + + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + nil, + ) + value := SimpleStruct{Name: "test", Value: 42} + expectedKVs, err := generator.Generate("my-object", value) + require.NoError(t, err) + require.Len(t, expectedKVs, 1) + + expectedOps := []operation.Operation{ + operation.Get([]byte(keys[0].Build())), + } + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{ + { + Values: []kv.KeyValue{expectedKVs[0]}, + }, + }, + } + + ctx := context.Background() + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + namedValue, err := typed.Get(ctx, "my-object") + require.NoError(t, err) + assert.Equal(t, "my-object", namedValue.Name) + assert.True(t, namedValue.Value.IsSome()) + + val, ok := namedValue.Value.Get() + require.True(t, ok) + assert.Equal(t, value, val) + + driverMock.MinimockFinish() +} + +func TestTypedGet_ExecutionError(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + ctx := context.Background() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 1) + + expectedOps := make([]operation.Operation, 0, len(keys)) + for _, key := range keys { + expectedOps = append(expectedOps, operation.Get([]byte(key.Build()))) + } + + expectedError := errors.New("driver execution failed") + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(tx.Response{Succeeded: false, Results: nil}, expectedError) + + _, err = typed.Get(ctx, "my-object") + require.Error(t, err) + require.ErrorContains(t, err, "failed to execute") + + driverMock.MinimockFinish() +} + +func TestTypedGet_NotFound(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + ctx := context.Background() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 1) + + expectedOps := make([]operation.Operation, 0, len(keys)) + for _, key := range keys { + expectedOps = append(expectedOps, operation.Get([]byte(key.Build()))) + } + + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{}, + } + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + _, err = typed.Get(ctx, "my-object") + require.Error(t, err) + require.ErrorIs(t, err, integrity.ErrNotFound) + + driverMock.MinimockFinish() +} + +func TestTypedGet_VerificationError(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + mockHasher := newMockHasher("sha256") + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithHasher(mockHasher). + Build() + + ctx := context.Background() + + namerInstance := namer.NewDefaultNamer("/test", []string{"sha256"}, []string{}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 2) + + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{mockHasher}, + nil, + ) + value := SimpleStruct{Name: "test", Value: 42} + expectedKVs, err := generator.Generate("my-object", value) + require.NoError(t, err) + require.Len(t, expectedKVs, 2) + + dataKV := expectedKVs[0] + + expectedOps := make([]operation.Operation, 0, len(keys)) + for _, key := range keys { + expectedOps = append(expectedOps, operation.Get([]byte(key.Build()))) + } + + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{ + { + Values: []kv.KeyValue{dataKV}, + }, + }, + } + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + _, err = typed.Get(ctx, "my-object") + require.Error(t, err) + require.ErrorContains(t, err, "hash") + + driverMock.MinimockFinish() +} + +func TestTypedGet_WithIgnoreVerificationError(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + mockHasher := newMockHasher("sha256") + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithHasher(mockHasher). + Build() + + ctx := context.Background() + + namerInstance := namer.NewDefaultNamer("/test", []string{"sha256"}, []string{}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 2) + + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{mockHasher}, + nil, + ) + value := SimpleStruct{Name: "test", Value: 42} + expectedKVs, err := generator.Generate("my-object", value) + require.NoError(t, err) + require.Len(t, expectedKVs, 2) + + dataKV := expectedKVs[0] + + expectedOps := make([]operation.Operation, 0, len(keys)) + for _, key := range keys { + expectedOps = append(expectedOps, operation.Get([]byte(key.Build()))) + } + + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{ + { + Values: []kv.KeyValue{dataKV}, + }, + }, + } + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + result, err := typed.Get(ctx, "my-object", integrity.IgnoreVerificationError()) + require.NoError(t, err) + assert.Equal(t, "my-object", result.Name) + require.Error(t, result.Error) + assert.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, value, val) + + driverMock.MinimockFinish() +} + +func TestTypedPut_Success(t *testing.T) { + t.Parallel() + + ctx := context.Background() + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{}) + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + nil, + ) + value := SimpleStruct{Name: "test", Value: 42} + expectedKVs, err := generator.Generate("my-object", value) + require.NoError(t, err) + require.Len(t, expectedKVs, 1) + + expectedOps := make([]operation.Operation, 0, len(expectedKVs)) + for _, expectedKV := range expectedKVs { + expectedOps = append(expectedOps, operation.Put(expectedKV.Key, expectedKV.Value)) + } + + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{}, + } + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + err = typed.Put(ctx, "my-object", value) + require.NoError(t, err) + + driverMock.MinimockFinish() +} + +func TestTypedDelete_Success(t *testing.T) { + t.Parallel() + + ctx := context.Background() + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 1) + + expectedOps := make([]operation.Operation, 0, len(keys)) + for _, key := range keys { + expectedOps = append(expectedOps, operation.Delete([]byte(key.Build()))) + } + + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{}, + } + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + err = typed.Delete(ctx, "my-object") + require.NoError(t, err) + + driverMock.MinimockFinish() +} + +func TestTypedDelete_TransactionExecutionError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 1) + + expectedOps := make([]operation.Operation, 0, len(keys)) + for _, key := range keys { + expectedOps = append(expectedOps, operation.Delete([]byte(key.Build()))) + } + + expectedError := errors.New("driver execution failed") + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(tx.Response{Succeeded: false, Results: nil}, expectedError) + + err = typed.Delete(ctx, "my-object") + require.Error(t, err) + require.ErrorContains(t, err, "failed to execute") + + driverMock.MinimockFinish() +} + +func TestTypedRange_Success(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + ctx := context.Background() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{}) + + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + nil, + ) + + value1 := SimpleStruct{Name: "obj1", Value: 100} + value2 := SimpleStruct{Name: "obj2", Value: 200} + + kvs1, err := generator.Generate("object1", value1) + require.NoError(t, err) + require.Len(t, kvs1, 1) + + kvs2, err := generator.Generate("object2", value2) + require.NoError(t, err) + require.Len(t, kvs2, 1) + + expectedPrefix := namerInstance.Prefix("", true) + expectedOps := []operation.Operation{ + operation.Get([]byte(expectedPrefix)), + } + + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{ + { + Values: []kv.KeyValue{kvs1[0], kvs2[0]}, + }, + }, + } + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + results, err := typed.Range(ctx, "") + require.NoError(t, err) + require.Len(t, results, 2) + + foundObj1 := false + foundObj2 := false + + for _, result := range results { + require.NoError(t, result.Error) + require.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + + if val.Name == "obj1" && val.Value == 100 { + foundObj1 = true + + assert.Equal(t, "object1", result.Name) + } else if val.Name == "obj2" && val.Value == 200 { + foundObj2 = true + + assert.Equal(t, "object2", result.Name) + } + } + + assert.True(t, foundObj1, "object1 not found in results") + assert.True(t, foundObj2, "object2 not found in results") + + driverMock.MinimockFinish() +} + +func TestTypedRange_WithValidationError(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + mockHasher := newMockHasher("sha256") + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithHasher(mockHasher). + Build() + + ctx := context.Background() + + namerInstance := namer.NewDefaultNamer("/test", []string{"sha256"}, []string{}) + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{mockHasher}, + nil, + ) + + value := SimpleStruct{Name: "obj1", Value: 100} + kvs, err := generator.Generate("object1", value) + require.NoError(t, err) + require.Len(t, kvs, 2) + + dataKV := kvs[0] + + expectedPrefix := namerInstance.Prefix("", true) + expectedOps := []operation.Operation{ + operation.Get([]byte(expectedPrefix)), + } + + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{ + { + Values: []kv.KeyValue{dataKV}, + }, + }, + } + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + results, err := typed.Range(ctx, "") + require.NoError(t, err) + assert.Empty(t, results, 0) + + driverMock.MinimockFinish() +} + +func TestTypedRange_WithIgnoreVerificationError(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + mockHasher := newMockHasher("sha256") + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithHasher(mockHasher). + Build() + + ctx := context.Background() + + namerInstance := namer.NewDefaultNamer("/test", []string{"sha256"}, []string{}) + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{mockHasher}, + nil, + ) + + value := SimpleStruct{Name: "obj1", Value: 100} + kvs, err := generator.Generate("object1", value) + require.NoError(t, err) + require.Len(t, kvs, 2) + + dataKV := kvs[0] + + expectedPrefix := namerInstance.Prefix("", true) + expectedOps := []operation.Operation{ + operation.Get([]byte(expectedPrefix)), + } + + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{ + { + Values: []kv.KeyValue{dataKV}, + }, + }, + } + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + results, err := typed.Range(ctx, "", integrity.IgnoreVerificationError()) + require.NoError(t, err) + require.Len(t, results, 1) + + assert.Equal(t, "object1", results[0].Name) + require.Error(t, results[0].Error) + assert.True(t, results[0].Value.IsSome()) + + val, ok := results[0].Value.Get() + require.True(t, ok) + assert.Equal(t, value, val) + + driverMock.MinimockFinish() +} + +func TestTypedRange_ExecutionError(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + ctx := context.Background() + + expectedPrefix := namer.NewDefaultNamer("/test", []string{}, []string{}).Prefix("", true) + expectedOps := []operation.Operation{ + operation.Get([]byte(expectedPrefix)), + } + + expectedError := errors.New("driver execution failed") + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(tx.Response{Succeeded: false, Results: nil}, expectedError) + + results, err := typed.Range(ctx, "") + require.Error(t, err) + require.ErrorContains(t, err, "failed to execute range") + require.Nil(t, results) + + driverMock.MinimockFinish() +} + +func TestTypedGet_WithHasher(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + mockHasher := newMockHasher("sha256") + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithHasher(mockHasher). + Build() + + namerInstance := namer.NewDefaultNamer("/test", []string{"sha256"}, []string{}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 2) + + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{mockHasher}, + nil, + ) + value := SimpleStruct{Name: "test", Value: 42} + expectedKVs, err := generator.Generate("my-object", value) + require.NoError(t, err) + require.Len(t, expectedKVs, 2) + + kvMap := make(map[string]kv.KeyValue, len(expectedKVs)) + for _, kvPair := range expectedKVs { + kvMap[string(kvPair.Key)] = kvPair + } + + var ( + expectedOps = make([]operation.Operation, 0, len(keys)) + results = make([]tx.RequestResponse, 0, len(keys)) + ) + + for _, key := range keys { + keyStr := key.Build() + + expectedOps = append(expectedOps, operation.Get([]byte(keyStr))) + + kvPair, ok := kvMap[keyStr] + require.True(t, ok, "missing expected KV for key %s", keyStr) + + results = append(results, tx.RequestResponse{ + Values: []kv.KeyValue{kvPair}, + }) + } + + response := tx.Response{ + Succeeded: true, + Results: results, + } + + ctx := context.Background() + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + namedValue, err := typed.Get(ctx, "my-object") + require.NoError(t, err) + assert.Equal(t, "my-object", namedValue.Name) + assert.True(t, namedValue.Value.IsSome()) + + val, ok := namedValue.Value.Get() + require.True(t, ok) + assert.Equal(t, value, val) + + driverMock.MinimockFinish() +} + +func TestTypedGet_WithHasherAndNamer(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + mockHasher := newMockHasher("sha256") + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithHasher(mockHasher). + Build() + + namerInstance := namer.NewDefaultNamer("/test", []string{"sha256"}, []string{}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 2) + + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{mockHasher}, + nil, + ) + value := SimpleStruct{Name: "test", Value: 42} + expectedKVs, err := generator.Generate("my-object", value) + require.NoError(t, err) + require.Len(t, expectedKVs, 2) + + kvMap := make(map[string]kv.KeyValue, len(expectedKVs)) + for _, kvPair := range expectedKVs { + kvMap[string(kvPair.Key)] = kvPair + } + + var ( + expectedOps = make([]operation.Operation, 0, len(keys)) + results = make([]tx.RequestResponse, 0, len(keys)) + ) + + for _, key := range keys { + keyStr := key.Build() + + expectedOps = append(expectedOps, operation.Get([]byte(keyStr))) + + kvPair, ok := kvMap[keyStr] + require.True(t, ok, "missing expected KV for key %s", keyStr) + + results = append(results, tx.RequestResponse{ + Values: []kv.KeyValue{kvPair}, + }) + } + + response := tx.Response{ + Succeeded: true, + Results: results, + } + + ctx := context.Background() + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + namedValue, err := typed.Get(ctx, "my-object") + require.NoError(t, err) + assert.Equal(t, "my-object", namedValue.Name) + assert.True(t, namedValue.Value.IsSome()) + + val, ok := namedValue.Value.Get() + require.True(t, ok) + assert.Equal(t, value, val) + + driverMock.MinimockFinish() +} + +func TestTypedPut_WithSigner(t *testing.T) { + t.Parallel() + + ctx := context.Background() + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + mockSigner := newMockSigner("rsa") + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithSigner(mockSigner). + Build() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{"rsa"}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 2) + + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + []crypto.Signer{mockSigner}, + ) + value := SimpleStruct{Name: "test", Value: 42} + expectedKVs, err := generator.Generate("my-object", value) + require.NoError(t, err) + require.Len(t, expectedKVs, 2) + + expectedOps := make([]operation.Operation, 0, len(expectedKVs)) + + for _, kv := range expectedKVs { + expectedOps = append(expectedOps, operation.Put(kv.Key, kv.Value)) + } + + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{}, + } + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + err = typed.Put(ctx, "my-object", value) + require.NoError(t, err) + + driverMock.MinimockFinish() +} + +func TestTypedGet_WithVerifier(t *testing.T) { + t.Parallel() + + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + mockVerifier := &mockVerifier{name: "rsa", verifyErr: nil} + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithVerifier(mockVerifier). + Build() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{"rsa"}) + keys, err := namerInstance.GenerateNames("my-object") + require.NoError(t, err) + require.Len(t, keys, 2) + + mockSigner := newMockSigner("rsa") + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + []crypto.Signer{mockSigner}, + ) + value := SimpleStruct{Name: "test", Value: 42} + expectedKVs, err := generator.Generate("my-object", value) + require.NoError(t, err) + require.Len(t, expectedKVs, 2) + + kvMap := make(map[string]kv.KeyValue, len(expectedKVs)) + for _, kvPair := range expectedKVs { + kvMap[string(kvPair.Key)] = kvPair + } + + var ( + expectedOps = make([]operation.Operation, 0, len(keys)) + results = make([]tx.RequestResponse, 0, len(keys)) + ) + + for _, key := range keys { + keyStr := key.Build() + + expectedOps = append(expectedOps, operation.Get([]byte(keyStr))) + + kvPair, ok := kvMap[keyStr] + require.True(t, ok, "missing expected KV for key %s", keyStr) + + results = append(results, tx.RequestResponse{ + Values: []kv.KeyValue{kvPair}, + }) + } + + response := tx.Response{ + Succeeded: true, + Results: results, + } + + ctx := context.Background() + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + namedValue, err := typed.Get(ctx, "my-object") + require.NoError(t, err) + assert.Equal(t, "my-object", namedValue.Name) + assert.True(t, namedValue.Value.IsSome()) + + val, ok := namedValue.Value.Get() + require.True(t, ok) + assert.Equal(t, value, val) + + driverMock.MinimockFinish() +} + +func TestTypedPut_GenerationError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + failingHasher := newMockHasherWithError("sha256", "hash computation failed") + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithHasher(failingHasher). + Build() + + value := SimpleStruct{Name: "test", Value: 42} + err := typed.Put(ctx, "my-object", value) + require.Error(t, err) + require.ErrorContains(t, err, "failed to generate") + require.ErrorContains(t, err, "hash computation failed") + + driverMock.MinimockFinish() +} + +func TestTypedPut_TransactionExecutionError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + Build() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{}) + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + nil, + ) + value := SimpleStruct{Name: "test", Value: 42} + expectedKVs, err := generator.Generate("my-object", value) + require.NoError(t, err) + require.Len(t, expectedKVs, 1) + + expectedOps := make([]operation.Operation, 0, len(expectedKVs)) + for _, expectedKV := range expectedKVs { + expectedOps = append(expectedOps, operation.Put(expectedKV.Key, expectedKV.Value)) + } + + expectedError := errors.New("driver execution failed") + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(tx.Response{Succeeded: false, Results: nil}, expectedError) + + err = typed.Put(ctx, "my-object", value) + require.Error(t, err) + require.ErrorContains(t, err, "failed to execute") + + driverMock.MinimockFinish() +} + +func TestTypedPut_SignerError(t *testing.T) { + t.Parallel() + + ctx := context.Background() + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + failingSigner := newMockSignerWithError("rsa", "signature generation failed") + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithSigner(failingSigner). + Build() + + value := SimpleStruct{Name: "test", Value: 42} + err := typed.Put(ctx, "my-object", value) + require.Error(t, err) + require.ErrorContains(t, err, "failed to generate") + require.ErrorContains(t, err, "signature generation failed") + + driverMock.MinimockFinish() +} + +func TestTypedBuilder_WithMarshaller(t *testing.T) { + t.Parallel() + + ctx := context.Background() + driverMock := mocks.NewDriverMock(t) + st := storage.NewStorage(driverMock) + + typed := integrity.NewTypedBuilder[SimpleStruct](st). + WithPrefix("/test"). + WithMarshaller(marshaller.NewTypedYamlMarshaller[SimpleStruct]()). + Build() + + namerInstance := namer.NewDefaultNamer("/test", []string{}, []string{}) + generator := integrity.NewGenerator[SimpleStruct]( + namerInstance, + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + nil, + ) + value := SimpleStruct{Name: "test", Value: 42} + expectedKVs, err := generator.Generate("my-object", value) + require.NoError(t, err) + require.Len(t, expectedKVs, 1) + + expectedOps := make([]operation.Operation, 0, len(expectedKVs)) + for _, expectedKV := range expectedKVs { + expectedOps = append(expectedOps, operation.Put(expectedKV.Key, expectedKV.Value)) + } + + response := tx.Response{ + Succeeded: true, + Results: []tx.RequestResponse{}, + } + + driverMock.ExecuteMock.Expect( + ctx, + nil, + expectedOps, + nil, + ).Return(response, nil) + + err = typed.Put(ctx, "my-object", value) + require.NoError(t, err) + + driverMock.MinimockFinish() +} diff --git a/integrity/utils.go b/integrity/utils.go deleted file mode 100644 index 2ecadab..0000000 --- a/integrity/utils.go +++ /dev/null @@ -1,3 +0,0 @@ -package integrity - -func zero[T any]() (out T) { return } //nolint:nonamedreturns diff --git a/integrity/validator.go b/integrity/validator.go index 389845a..e552e5b 100644 --- a/integrity/validator.go +++ b/integrity/validator.go @@ -3,6 +3,8 @@ package integrity import ( "bytes" + "github.com/tarantool/go-option" + "github.com/tarantool/go-storage/crypto" "github.com/tarantool/go-storage/hasher" "github.com/tarantool/go-storage/kv" @@ -12,7 +14,7 @@ import ( // Validator verifies integrity-protected key-value pairs. type Validator[T any] struct { - namer *namer.DefaultNamer + namer namer.Namer marshaller marshaller.TypedMarshaller[T] hashers map[string]hasher.Hasher verifiers map[string]crypto.Verifier @@ -20,7 +22,7 @@ type Validator[T any] struct { // NewValidator creates a new Validator instance. func NewValidator[T any]( - namer *namer.DefaultNamer, + namer namer.Namer, marshaller marshaller.TypedMarshaller[T], hashers []hasher.Hasher, verifiers []crypto.Verifier, @@ -43,14 +45,6 @@ func NewValidator[T any]( } } -// ValidateResult represents the result of validating a single object. -type ValidateResult[T any] struct { - Name string - HasValue bool - Value T - Error error -} - type extendedKV struct { parsedKey namer.Key keyValue kv.KeyValue @@ -96,17 +90,16 @@ func (v Validator[T]) constructVerifiers() map[string]crypto.Verifier { return out } -func (v Validator[T]) validateSingle(name string, kvs []extendedKV) ValidateResult[T] { +func (v Validator[T]) validateSingle(name string, kvs []extendedKV) ValidatedResult[T] { expectedHashers := v.constructHashers() expectedVerifiers := v.constructVerifiers() aggregatedError := &FailedToValidateAggregatedError{parent: nil} - output := ValidateResult[T]{ - Name: name, - HasValue: false, - Value: zero[T](), - Error: nil, + output := ValidatedResult[T]{ + Name: name, + Value: option.None[T](), + Error: nil, } var ( @@ -125,8 +118,7 @@ func (v Validator[T]) validateSingle(name string, kvs []extendedKV) ValidateResu return output } - output.HasValue = true - output.Value = val + output.Value = option.Some(val) body = kvi.keyValue.Value } @@ -182,13 +174,13 @@ func (v Validator[T]) validateSingle(name string, kvs []extendedKV) ValidateResu } // Validate verifies integrity-protected key-value pairs and returns the validated value. -func (v Validator[T]) Validate(kvs []kv.KeyValue) ([]ValidateResult[T], error) { +func (v Validator[T]) Validate(kvs []kv.KeyValue) ([]ValidatedResult[T], error) { parseKeyResult, err := v.aggregate(kvs) if err != nil { return nil, err } - out := make([]ValidateResult[T], 0, len(parseKeyResult)) + out := make([]ValidatedResult[T], 0, len(parseKeyResult)) for name, keys := range parseKeyResult { out = append(out, v.validateSingle(name, keys)) } diff --git a/integrity/validator_test.go b/integrity/validator_test.go index 673fe38..d437d18 100644 --- a/integrity/validator_test.go +++ b/integrity/validator_test.go @@ -84,11 +84,12 @@ func TestValidatorValidate_Success(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.True(t, result.HasValue) - assert.Equal(t, value, result.Value) - require.NoError(t, result.Error) -} + assert.True(t, result.Value.IsSome()) + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, value, val) +} func TestValidatorValidate_MissingHash(t *testing.T) { t.Parallel() @@ -120,8 +121,11 @@ func TestValidatorValidate_MissingHash(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.True(t, result.HasValue) - assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, result.Value) + assert.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, val) require.ErrorAs(t, result.Error, &integrity.ValidationError{}) require.ErrorContains(t, result.Error, "hash \"sha256\" not verified (missing)") } @@ -160,8 +164,11 @@ func TestValidatorValidate_HashMismatch(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.True(t, result.HasValue) - assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, result.Value) + assert.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, val) require.ErrorAs(t, result.Error, &integrity.ValidationError{}) require.ErrorContains(t, result.Error, "hash mismatch for \"sha256\"") } @@ -217,7 +224,7 @@ func TestValidatorValidate_MultipleObjects(t *testing.T) { require.Len(t, validatedResults, 2) // Find results by name. - var result1, result2 integrity.ValidateResult[SimpleStruct] + var result1, result2 integrity.ValidatedResult[SimpleStruct] for _, res := range validatedResults { switch res.Name { @@ -229,13 +236,19 @@ func TestValidatorValidate_MultipleObjects(t *testing.T) { } assert.Equal(t, "object1", result1.Name) - assert.True(t, result1.HasValue) - assert.Equal(t, SimpleStruct{Name: "test1", Value: 42}, result1.Value) + assert.True(t, result1.Value.IsSome()) + + val1, ok := result1.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test1", Value: 42}, val1) require.NoError(t, result1.Error) assert.Equal(t, "object2", result2.Name) - assert.True(t, result2.HasValue) - assert.Equal(t, SimpleStruct{Name: "test2", Value: 100}, result2.Value) + assert.True(t, result2.Value.IsSome()) + + val2, ok := result2.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test2", Value: 100}, val2) require.NoError(t, result2.Error) } @@ -287,7 +300,7 @@ func TestValidatorValidate_PartialSuccess(t *testing.T) { require.Len(t, validatedResults, 2) // Find results. - var result1, result2 integrity.ValidateResult[SimpleStruct] + var result1, result2 integrity.ValidatedResult[SimpleStruct] for _, res := range validatedResults { switch res.Name { @@ -300,14 +313,20 @@ func TestValidatorValidate_PartialSuccess(t *testing.T) { // object1 should be valid. assert.Equal(t, "object1", result1.Name) - assert.True(t, result1.HasValue) - assert.Equal(t, SimpleStruct{Name: "test1", Value: 42}, result1.Value) + assert.True(t, result1.Value.IsSome()) + + val1, ok := result1.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test1", Value: 42}, val1) require.NoError(t, result1.Error) // object2 should have hash mismatch error. assert.Equal(t, "object2", result2.Name) - assert.True(t, result2.HasValue) - assert.Equal(t, SimpleStruct{Name: "test2", Value: 100}, result2.Value) + assert.True(t, result2.Value.IsSome()) + + val2, ok := result2.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test2", Value: 100}, val2) require.ErrorAs(t, result2.Error, &integrity.ValidationError{}) require.ErrorContains(t, result2.Error, "hash mismatch for \"sha256\"") } @@ -343,8 +362,11 @@ func TestValidatorValidate_MissingSignature(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.True(t, result.HasValue) - assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, result.Value) + assert.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, val) require.ErrorAs(t, result.Error, &integrity.ValidationError{}) require.ErrorContains(t, result.Error, "signature \"rsa\" not verified (missing)") } @@ -385,8 +407,11 @@ func TestValidatorValidate_SignatureVerificationError(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.True(t, result.HasValue) - assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, result.Value) + assert.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, val) require.ErrorAs(t, result.Error, &integrity.ValidationError{}) require.ErrorContains(t, result.Error, "signature verification failed for \"rsa\"") } @@ -430,8 +455,11 @@ func TestValidatorValidate_HashComputationError(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.True(t, result.HasValue) - assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, result.Value) + assert.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, val) require.ErrorAs(t, result.Error, &integrity.ValidationError{}) require.ErrorContains(t, result.Error, "failed to calculate hash \"sha256\"") } @@ -500,8 +528,11 @@ func TestValidatorValidate_HasherNotAvailable(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.True(t, result.HasValue) - assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, result.Value) + assert.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, val) require.NoError(t, result.Error) } @@ -546,8 +577,11 @@ func TestValidatorValidate_VerifierNotAvailable(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.True(t, result.HasValue) - assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, result.Value) + assert.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, val) require.NoError(t, result.Error) } @@ -602,7 +636,7 @@ func TestValidatorValidate_MissingValueKey(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.False(t, result.HasValue) + assert.True(t, result.Value.IsZero()) require.ErrorAs(t, result.Error, &integrity.ValidationError{}) // When there's no value key, the validator still tries to compute hashes on nil data. require.ErrorContains(t, result.Error, "failed to calculate hash") @@ -652,7 +686,7 @@ func TestValidatorValidate_UnmarshalError(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.False(t, result.HasValue) + assert.True(t, result.Value.IsZero()) require.ErrorAs(t, result.Error, &integrity.ValidationError{}) require.ErrorContains(t, result.Error, "failed to unmarshal record") } @@ -696,8 +730,11 @@ func TestValidatorValidate_HashKeyNotFound(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.True(t, result.HasValue) - assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, result.Value) + assert.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, val) require.ErrorAs(t, result.Error, &integrity.ValidationError{}) require.ErrorContains(t, result.Error, "hash \"sha1\" not verified (missing)") } @@ -742,8 +779,11 @@ func TestValidatorValidate_SignatureKeyNotFound(t *testing.T) { result := validatedResults[0] assert.Equal(t, "my-object", result.Name) - assert.True(t, result.HasValue) - assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, result.Value) + assert.True(t, result.Value.IsSome()) + + val, ok := result.Value.Get() + require.True(t, ok) + assert.Equal(t, SimpleStruct{Name: "test", Value: 42}, val) require.ErrorAs(t, result.Error, &integrity.ValidationError{}) require.ErrorContains(t, result.Error, "signature \"ecdsa\" not verified (missing)") } diff --git a/internal/options/options.go b/internal/options/options.go new file mode 100644 index 0000000..4ce0a9d --- /dev/null +++ b/internal/options/options.go @@ -0,0 +1,19 @@ +package options + +type OptionConstructor[T any] func() T + +type OptionCallback[T any] func(*T) + +func ApplyOptions[T any](constructor OptionConstructor[T], cbs []OptionCallback[T]) T { + var opts T + + if constructor != nil { + opts = constructor() + } + + for _, cb := range cbs { + cb(&opts) + } + + return opts +} diff --git a/namer/namer.go b/namer/namer.go index 7a038b7..b43f346 100644 --- a/namer/namer.go +++ b/namer/namer.go @@ -17,6 +17,7 @@ type Namer interface { GenerateNames(name string) ([]Key, error) ParseKey(name string) (DefaultKey, error) ParseKeys(names []string, ignoreError bool) (Results, error) + Prefix(val string, isPrefix bool) string } // DefaultNamer represents default namer. @@ -27,7 +28,7 @@ type DefaultNamer struct { } // NewDefaultNamer returns new DefaultNamer object with hash/signature names configuration. -func NewDefaultNamer(prefix string, hashNames []string, sigNames []string) *DefaultNamer { +func NewDefaultNamer(prefix string, hashNames []string, sigNames []string) Namer { return &DefaultNamer{ prefix: strings.Trim(prefix, "/"), hashNames: hashNames, @@ -126,6 +127,18 @@ func (n *DefaultNamer) ParseKeys(names []string, ignoreError bool) (Results, err return NewResults(out), nil } +// Prefix returns the prefix used by this namer. +func (n *DefaultNamer) Prefix(val string, isPrefix bool) string { + suffix := strings.Trim(val, "/") + + switch { + case isPrefix: + return fmt.Sprintf("/%s/%s/", n.prefix, suffix) + default: + return fmt.Sprintf("/%s/%s", n.prefix, suffix) + } +} + // parseSignatureKey parses a signature key from name parts. func (n *DefaultNamer) parseSignatureKey(nameParts []string, originalName string) (DefaultKey, error) { switch { diff --git a/storage.go b/storage.go index 68e29ed..b0fae9c 100644 --- a/storage.go +++ b/storage.go @@ -3,6 +3,8 @@ package storage import ( "context" "fmt" + "strings" + "sync" "github.com/tarantool/go-option" @@ -84,8 +86,41 @@ type storage struct { } // Watch implements the Storage interface for watching key changes. -func (s storage) Watch(_ context.Context, _ []byte, _ ...watch.Option) <-chan watch.Event { - panic("implement me") +func (s storage) Watch(ctx context.Context, key []byte, opts ...watch.Option) <-chan watch.Event { + eventCh, cleanup, err := s.driver.Watch(ctx, key, opts...) + if err != nil { + // Return a closed channel on error. + ch := make(chan watch.Event) + close(ch) + + return ch + } + + if cleanup != nil { + var once sync.Once + + wrapperChan := make(chan watch.Event, cap(eventCh)) + + go func() { // Forwarding goroutine. + defer close(wrapperChan) + + for event := range eventCh { + wrapperChan <- event + } + + once.Do(cleanup) + }() + + // Cleanup goroutine on context cancellation. + go func() { + <-ctx.Done() + once.Do(cleanup) + }() + + return wrapperChan + } + + return eventCh } // Tx implements the Storage interface for transaction creation. @@ -94,8 +129,35 @@ func (s storage) Tx(ctx context.Context) txPkg.Tx { } // Range implements the Storage interface for range queries. -func (s storage) Range(_ context.Context, _ ...RangeOption) ([]kv.KeyValue, error) { - panic("implement me") +func (s storage) Range(ctx context.Context, opts ...RangeOption) ([]kv.KeyValue, error) { + rangeOpts := &rangeOptions{Prefix: "", Limit: 0} + for _, opt := range opts { + opt(rangeOpts) + } + + if rangeOpts.Prefix == "" { + return nil, nil + } + + // Create a Get operation with the prefix. + key := rangeOpts.Prefix + if key != "" && !strings.HasSuffix(key, "/") { + key += "/" + } + + ops := []operation.Operation{operation.Get([]byte(key))} + + response, err := s.driver.Execute(ctx, nil, ops, nil) + if err != nil { + return nil, err + } + + var kvs []kv.KeyValue + for _, r := range response.Results { + kvs = append(kvs, r.Values...) + } + + return kvs, nil } // NewStorage creates a new Storage instance with the specified driver.