From 2c44171a5d1808c5e9319e1d2d686594f627cbf7 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Tue, 9 Dec 2025 04:09:53 +0300 Subject: [PATCH] integrity: implement basic structs needed for storage * generator to generate needed key-values * validator to validate existing key-values * removed gocognit/funlen from list of linters, disabled revive check * implemented typed marshaller, removed old one Part of TNTP-4191 --- .golangci.yml | 28 +- crypto/rsapss.go | 2 +- driver/etcd/integration_test.go | 4 +- hasher/hasher_test.go | 1 + integrity/error.go | 249 +++++++++++ integrity/error_test.go | 477 ++++++++++++++++++++ integrity/generator.go | 100 +++++ integrity/generator_test.go | 381 ++++++++++++++++ integrity/utils.go | 3 + integrity/validator.go | 197 +++++++++ integrity/validator_test.go | 749 ++++++++++++++++++++++++++++++++ marshaller/error.go | 51 +++ marshaller/error_test.go | 124 ++++++ marshaller/interface.go | 24 + marshaller/marshaller.go | 59 --- marshaller/marshaller_test.go | 47 -- marshaller/typed.go | 35 ++ marshaller/typed_test.go | 243 +++++++++++ marshaller/utils.go | 3 + namer/key.go | 17 + namer/key_test.go | 49 +++ namer/namer.go | 7 + 22 files changed, 2732 insertions(+), 118 deletions(-) create mode 100644 integrity/error.go create mode 100644 integrity/error_test.go create mode 100644 integrity/generator.go create mode 100644 integrity/generator_test.go create mode 100644 integrity/utils.go create mode 100644 integrity/validator.go create mode 100644 integrity/validator_test.go create mode 100644 marshaller/error.go create mode 100644 marshaller/error_test.go create mode 100644 marshaller/interface.go delete mode 100644 marshaller/marshaller.go delete mode 100644 marshaller/marshaller_test.go create mode 100644 marshaller/typed.go create mode 100644 marshaller/typed_test.go create mode 100644 marshaller/utils.go diff --git a/.golangci.yml b/.golangci.yml index 6b7adac..6780a54 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,4 +1,4 @@ -version: '2' +version: "2" run: timeout: 3m @@ -9,20 +9,22 @@ formatters: issues: # Disable limits on the number of printed issues - max-issues-per-linter: 0 # 0 = no limit - max-same-issues: 0 # 0 = no limit + max-issues-per-linter: 0 # 0 = no limit + max-same-issues: 0 # 0 = no limit linters: default: all disable: - - dupl # Dupl is disabled, since we're generating a lot of boilerplate code. - - cyclop # Cyclop is disabled, since cyclomatic complexities is very abstract metric, - # that sometimes lead to strange linter behaviour. - - wsl # WSL is disabled, since it's obsolete. Using WSL_v5. + - dupl # Dupl is disabled, since we're generating a lot of boilerplate code. + - cyclop # Cyclop is disabled, since cyclomatic complexities is very abstract metric, + # that sometimes lead to strange linter behaviour. + - wsl # WSL is disabled, since it's obsolete. Using WSL_v5. - nlreturn # nlreturn is disabled, since it's duplicated by wsl_v5.return check. - - ireturn # ireturn is disabled, since it's not needed. - - godox # godox is disabled to allow TODO comments for unimplemented functionality. + - ireturn # ireturn is disabled, since it's not needed. + - godox # godox is disabled to allow TODO comments for unimplemented functionality. + - gocognit # gocognit is disabled, cognitive complexity is too restrictive. + - funlen # funlen is disabled, function length limits are too restrictive. exclusions: generated: lax @@ -40,6 +42,14 @@ linters: ignore-decls: - mc *minimock.Controller - t T + revive: + rules: + - name: var-naming + arguments: + - [] + - [] + - - skip-package-name-checks: true + godot: scope: all lll: diff --git a/crypto/rsapss.go b/crypto/rsapss.go index fcc2b51..452cd93 100644 --- a/crypto/rsapss.go +++ b/crypto/rsapss.go @@ -1,4 +1,4 @@ -package crypto //nolint:revive +package crypto import ( "crypto" diff --git a/driver/etcd/integration_test.go b/driver/etcd/integration_test.go index 2a135d6..30024e6 100644 --- a/driver/etcd/integration_test.go +++ b/driver/etcd/integration_test.go @@ -213,8 +213,8 @@ func TestEtcdDriver_VersionEqualPredicate(t *testing.T) { putValue(ctx, t, driver, key, value) - kv := getValue(ctx, t, driver, key) - initialRevision := kv.ModRevision + kvi := getValue(ctx, t, driver, key) + initialRevision := kvi.ModRevision response, err := driver.Execute(ctx, []predicate.Predicate{ predicate.VersionEqual(key, initialRevision), diff --git a/hasher/hasher_test.go b/hasher/hasher_test.go index 97ccab5..3eaec1e 100644 --- a/hasher/hasher_test.go +++ b/hasher/hasher_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/stretchr/testify/assert" + "github.com/tarantool/go-storage/hasher" ) diff --git a/integrity/error.go b/integrity/error.go new file mode 100644 index 0000000..37aeee9 --- /dev/null +++ b/integrity/error.go @@ -0,0 +1,249 @@ +package integrity + +import ( + "encoding/hex" + "fmt" + "strings" +) + +// ImpossibleError represents an error when an integrity operation cannot be performed due to internal problems. +type ImpossibleError struct { + text string +} + +func errImpossibleHasher(property string) error { + return ImpossibleError{ + text: "hasher not found: " + property, + } +} + +func errImpossibleSigner(property string) error { + return ImpossibleError{ + text: "signer not found: " + property, + } +} + +func errImpossibleKeyType(keyType string) error { + return ImpossibleError{ + text: "unknown key type: " + keyType, + } +} + +func (e ImpossibleError) Error() string { + return e.text +} + +// ValidationError represents an error when validation fails. +type ValidationError struct { + text string + parent error +} + +// Error returns a string representation of the validation error. +func (e ValidationError) Error() string { + if e.parent == nil { + return e.text + } + + return fmt.Sprintf("%s: %s", e.text, e.parent) +} + +func (e ValidationError) Unpack() error { + return e.parent +} + +// FailedToGenerateKeysError represents an error when key generation fails. +type FailedToGenerateKeysError struct { + parent error +} + +func errFailedToGenerateKeys(parent error) error { + return FailedToGenerateKeysError{parent: parent} +} + +// Unwrap returns the underlying error that caused the key generation failure. +func (e FailedToGenerateKeysError) Unwrap() error { + return e.parent +} + +// Error returns a string representation of the key generation error. +func (e FailedToGenerateKeysError) Error() string { + if e.parent == nil { + return "failed to generate keys" + } + + return "failed to generate keys: " + e.parent.Error() +} + +// FailedToMarshalValueError represents an error when value marshalling fails. +type FailedToMarshalValueError struct { + parent error +} + +func errFailedToMarshalValue(parent error) error { + return FailedToMarshalValueError{parent: parent} +} + +// Unwrap returns the underlying error that caused the marshalling failure. +func (e FailedToMarshalValueError) Unwrap() error { + return e.parent +} + +// Error returns a string representation of the marshalling error. +func (e FailedToMarshalValueError) Error() string { + if e.parent == nil { + return "failed to marshal value" + } + + return "failed to marshal value: " + e.parent.Error() +} + +// FailedToComputeHashError represents an error when hash computation fails. +type FailedToComputeHashError struct { + parent error +} + +func errFailedToComputeHash(parent error) error { + return FailedToComputeHashError{parent: parent} +} + +// Unwrap returns the underlying error that caused the hash computation failure. +func (e FailedToComputeHashError) Unwrap() error { + return e.parent +} + +// Error returns a string representation of the hash computation error. +func (e FailedToComputeHashError) Error() string { + if e.parent == nil { + return "failed to compute hash" + } + + return "failed to compute hash: " + e.parent.Error() +} + +// FailedToGenerateSignatureError represents an error when signature generation fails. +type FailedToGenerateSignatureError struct { + parent error +} + +func errFailedToGenerateSignature(parent error) error { + return FailedToGenerateSignatureError{parent: parent} +} + +// Unwrap returns the underlying error that caused the signature generation failure. +func (e FailedToGenerateSignatureError) Unwrap() error { + return e.parent +} + +// Error returns a string representation of the signature generation error. +func (e FailedToGenerateSignatureError) Error() string { + if e.parent == nil { + return "failed to generate signature" + } + + return "failed to generate signature: " + e.parent.Error() +} + +// FailedToValidateAggregatedError represents aggregated validation errors. +type FailedToValidateAggregatedError struct { + parent []error +} + +func errHashNotVerifiedMissing(hasherName string) error { + return ValidationError{ + text: fmt.Sprintf("hash \"%s\" not verified (missing)", hasherName), + parent: nil, + } +} + +func errSignatureNotVerifiedMissing(verifierName string) error { + return ValidationError{ + text: fmt.Sprintf("signature \"%s\" not verified (missing)", verifierName), + parent: nil, + } +} + +func errHashMismatch(hasherName string, expected, got []byte) error { + return ValidationError{ + text: fmt.Sprintf("hash mismatch for \"%s\"", hasherName), + parent: hashMismatchDetailError{expected: expected, got: got}, + } +} + +type hashMismatchDetailError struct { + expected []byte + got []byte +} + +func (h hashMismatchDetailError) Error() string { + return fmt.Sprintf("expected \"%s\", got \"%s\"", hex.Dump(h.expected), hex.Dump(h.got)) +} + +func errFailedToParseKey(parent error) error { + return ValidationError{ + text: "failed to parse key", + parent: parent, + } +} + +func errFailedToComputeHashWith(hasherName string, parent error) error { + return ValidationError{ + text: fmt.Sprintf("failed to calculate hash \"%s\"", hasherName), + parent: parent, + } +} + +func errSignatureVerificationFailed(verifierName string, parent error) error { + return ValidationError{ + text: fmt.Sprintf("signature verification failed for \"%s\"", verifierName), + parent: parent, + } +} + +func errFailedToUnmarshal(parent error) error { + return ValidationError{ + text: "failed to unmarshal record", + parent: parent, + } +} + +// Unwrap returns the underlying slice of errors. +func (e *FailedToValidateAggregatedError) Unwrap() []error { + return e.parent +} + +// Append adds an error to the aggregated error. +func (e *FailedToValidateAggregatedError) Append(err error) { + if err != nil { + e.parent = append(e.parent, err) + } +} + +// Error returns a string representation of the aggregated error. +func (e *FailedToValidateAggregatedError) Error() string { + switch { + case len(e.parent) == 0: + return "" + case len(e.parent) == 1: + return e.parent[0].Error() + default: + var errStrings []string + for _, p := range e.parent { + errStrings = append(errStrings, p.Error()) + } + + return "aggregated error: " + strings.Join(errStrings, ", ") + } +} + +// Finalize returns nil if there are no errors, otherwise returns error or the aggregated error. +func (e *FailedToValidateAggregatedError) Finalize() error { + switch len(e.parent) { + case 0: + return nil + case 1: + return e.parent[0] + default: + return e + } +} diff --git a/integrity/error_test.go b/integrity/error_test.go new file mode 100644 index 0000000..2ca8d07 --- /dev/null +++ b/integrity/error_test.go @@ -0,0 +1,477 @@ +// Package integrity provides integrity storage. +package integrity //nolint:testpackage + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestImpossibleError_Error(t *testing.T) { + t.Parallel() + + err := ImpossibleError{text: "test error"} + assert.Equal(t, "test error", err.Error()) +} + +func Test_errImpossibleHasher(t *testing.T) { + t.Parallel() + + err := errImpossibleHasher("sha256") + assert.Equal(t, "hasher not found: sha256", err.Error()) + + var impossibleErr ImpossibleError + require.ErrorAs(t, err, &impossibleErr) +} + +func Test_errImpossibleSigner(t *testing.T) { + t.Parallel() + + err := errImpossibleSigner("rsa") + assert.Equal(t, "signer not found: rsa", err.Error()) + + var impossibleErr ImpossibleError + require.ErrorAs(t, err, &impossibleErr) +} + +func Test_errImpossibleKeyType(t *testing.T) { + t.Parallel() + + err := errImpossibleKeyType("unknown") + assert.Equal(t, "unknown key type: unknown", err.Error()) + + var impossibleErr ImpossibleError + require.ErrorAs(t, err, &impossibleErr) +} + +func TestValidationError_Error(t *testing.T) { + t.Parallel() + + // Test with parent error. + parentErr := errors.New("parent error") + err := ValidationError{ + text: "validation failed", + parent: parentErr, + } + assert.Equal(t, "validation failed: parent error", err.Error()) + + // Test without parent error. + err = ValidationError{ + text: "validation failed", + parent: nil, + } + assert.Equal(t, "validation failed", err.Error()) +} + +func TestValidationError_Unpack(t *testing.T) { + t.Parallel() + + // Test with parent error. + parentErr := errors.New("original error") + validationErr := ValidationError{ + text: "validation failed", + parent: parentErr, + } + assert.Equal(t, parentErr, validationErr.Unpack()) + + // Test with nil parent. + validationErr = ValidationError{ + text: "validation failed", + parent: nil, + } + require.NoError(t, validationErr.Unpack()) +} + +func TestFailedToGenerateKeysError_Error(t *testing.T) { + t.Parallel() + + // Test with parent error. + parentErr := errors.New("namer error") + err := FailedToGenerateKeysError{parent: parentErr} + assert.Equal(t, "failed to generate keys: namer error", err.Error()) + + // Test without parent error. + err = FailedToGenerateKeysError{parent: nil} + assert.Equal(t, "failed to generate keys", err.Error()) +} + +func TestFailedToGenerateKeysError_Unwrap(t *testing.T) { + t.Parallel() + + parentErr := errors.New("namer error") + err := FailedToGenerateKeysError{parent: parentErr} + assert.Equal(t, parentErr, err.Unwrap()) + + err = FailedToGenerateKeysError{parent: nil} + require.NoError(t, err.Unwrap()) +} + +func Test_errFailedToGenerateKeys(t *testing.T) { + t.Parallel() + + parentErr := errors.New("namer error") + err := errFailedToGenerateKeys(parentErr) + assert.Equal(t, "failed to generate keys: namer error", err.Error()) + + var failedErr FailedToGenerateKeysError + require.ErrorAs(t, err, &failedErr) + assert.Equal(t, parentErr, failedErr.Unwrap()) + + // Test without parent error. + err = errFailedToGenerateKeys(nil) + assert.Equal(t, "failed to generate keys", err.Error()) +} + +func TestFailedToMarshalValueError_Error(t *testing.T) { + t.Parallel() + + // Test with parent error. + parentErr := errors.New("yaml marshal error") + err := FailedToMarshalValueError{parent: parentErr} + assert.Equal(t, "failed to marshal value: yaml marshal error", err.Error()) + + // Test without parent error. + err = FailedToMarshalValueError{parent: nil} + assert.Equal(t, "failed to marshal value", err.Error()) +} + +func TestFailedToMarshalValueError_Unwrap(t *testing.T) { + t.Parallel() + + parentErr := errors.New("yaml marshal error") + err := FailedToMarshalValueError{parent: parentErr} + assert.Equal(t, parentErr, err.Unwrap()) + + err = FailedToMarshalValueError{parent: nil} + require.NoError(t, err.Unwrap()) +} + +func Test_errFailedToMarshalValue(t *testing.T) { + t.Parallel() + + parentErr := errors.New("yaml marshal error") + err := errFailedToMarshalValue(parentErr) + assert.Equal(t, "failed to marshal value: yaml marshal error", err.Error()) + + var failedErr FailedToMarshalValueError + require.ErrorAs(t, err, &failedErr) + assert.Equal(t, parentErr, failedErr.Unwrap()) + + // Test without parent error. + err = errFailedToMarshalValue(nil) + assert.Equal(t, "failed to marshal value", err.Error()) +} + +func TestFailedToComputeHashError_Error(t *testing.T) { + t.Parallel() + + // Test with parent error. + parentErr := errors.New("hash computation failed") + err := FailedToComputeHashError{parent: parentErr} + assert.Equal(t, "failed to compute hash: hash computation failed", err.Error()) + + // Test without parent error. + err = FailedToComputeHashError{parent: nil} + assert.Equal(t, "failed to compute hash", err.Error()) +} + +func TestFailedToComputeHashError_Unwrap(t *testing.T) { + t.Parallel() + + parentErr := errors.New("hash computation failed") + err := FailedToComputeHashError{parent: parentErr} + assert.Equal(t, parentErr, err.Unwrap()) + + err = FailedToComputeHashError{parent: nil} + require.NoError(t, err.Unwrap()) +} + +func Test_errFailedToComputeHash(t *testing.T) { + t.Parallel() + + parentErr := errors.New("hash computation failed") + err := errFailedToComputeHash(parentErr) + assert.Equal(t, "failed to compute hash: hash computation failed", err.Error()) + + var failedErr FailedToComputeHashError + require.ErrorAs(t, err, &failedErr) + assert.Equal(t, parentErr, failedErr.Unwrap()) + + // Test without parent error. + err = errFailedToComputeHash(nil) + assert.Equal(t, "failed to compute hash", err.Error()) +} + +func TestFailedToGenerateSignatureError_Error(t *testing.T) { + t.Parallel() + + // Test with parent error. + parentErr := errors.New("signature generation failed") + err := FailedToGenerateSignatureError{parent: parentErr} + assert.Equal(t, "failed to generate signature: signature generation failed", err.Error()) + + // Test without parent error. + err = FailedToGenerateSignatureError{parent: nil} + assert.Equal(t, "failed to generate signature", err.Error()) +} + +func TestFailedToGenerateSignatureError_Unwrap(t *testing.T) { + t.Parallel() + + parentErr := errors.New("signature generation failed") + err := FailedToGenerateSignatureError{parent: parentErr} + assert.Equal(t, parentErr, err.Unwrap()) + + err = FailedToGenerateSignatureError{parent: nil} + require.NoError(t, err.Unwrap()) +} + +func Test_errFailedToGenerateSignature(t *testing.T) { + t.Parallel() + + parentErr := errors.New("signature generation failed") + err := errFailedToGenerateSignature(parentErr) + assert.Equal(t, "failed to generate signature: signature generation failed", err.Error()) + + var failedErr FailedToGenerateSignatureError + require.ErrorAs(t, err, &failedErr) + assert.Equal(t, parentErr, failedErr.Unwrap()) + + // Test without parent error. + err = errFailedToGenerateSignature(nil) + assert.Equal(t, "failed to generate signature", err.Error()) +} + +func Test_errHashNotVerifiedMissing(t *testing.T) { + t.Parallel() + + err := errHashNotVerifiedMissing("sha1") + assert.Equal(t, "hash \"sha1\" not verified (missing)", err.Error()) + + var validationErr ValidationError + require.ErrorAs(t, err, &validationErr) +} + +func Test_errSignatureNotVerifiedMissing(t *testing.T) { + t.Parallel() + + err := errSignatureNotVerifiedMissing("ecdsa") + assert.Equal(t, "signature \"ecdsa\" not verified (missing)", err.Error()) + + var validationErr ValidationError + require.ErrorAs(t, err, &validationErr) +} + +func Test_hashMismatchDetailError_Error(t *testing.T) { + t.Parallel() + + err := hashMismatchDetailError{ + expected: []byte("expected"), + got: []byte("got"), + } + // Note: hex.Dump adds newlines and formatting. + require.ErrorContains(t, err, "expected") + require.ErrorContains(t, err, "got") +} + +func Test_errHashMismatch(t *testing.T) { + t.Parallel() + + err := errHashMismatch("sha256", []byte("expected"), []byte("got")) + require.ErrorContains(t, err, "hash mismatch for \"sha256\"") + require.ErrorContains(t, err, "expected") + require.ErrorContains(t, err, "got") + + var validationErr ValidationError + require.ErrorAs(t, err, &validationErr) +} + +func Test_errFailedToParseKey(t *testing.T) { + t.Parallel() + + parentErr := errors.New("invalid key") + err := errFailedToParseKey(parentErr) + assert.Equal(t, "failed to parse key: invalid key", err.Error()) + + var validationErr ValidationError + require.ErrorAs(t, err, &validationErr) +} + +func Test_errFailedToComputeHashWith(t *testing.T) { + t.Parallel() + + parentErr := errors.New("hash computation failed") + err := errFailedToComputeHashWith("sha256", parentErr) + require.ErrorContains(t, err, "failed to calculate hash \"sha256\": hash computation failed") + + var validationErr ValidationError + require.ErrorAs(t, err, &validationErr) +} + +func Test_errSignatureVerificationFailed(t *testing.T) { + t.Parallel() + + parentErr := errors.New("invalid signature") + err := errSignatureVerificationFailed("rsa", parentErr) + assert.Equal(t, "signature verification failed for \"rsa\": invalid signature", err.Error()) + + var validationErr ValidationError + require.ErrorAs(t, err, &validationErr) +} + +func Test_errFailedToUnmarshal(t *testing.T) { + t.Parallel() + + parentErr := errors.New("invalid yaml") + err := errFailedToUnmarshal(parentErr) + assert.Equal(t, "failed to unmarshal record: invalid yaml", err.Error()) + + var validationErr ValidationError + require.ErrorAs(t, err, &validationErr) +} + +func TestFailedToValidateAggregatedError_Unwrap(t *testing.T) { + t.Parallel() + + t.Run("nil parent", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: nil} + assert.Empty(t, aggErr.Unwrap()) + }) + + t.Run("multiple parents", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: []error{errors.New("error1"), errors.New("error2")}} + + errs := aggErr.Unwrap() + assert.Len(t, errs, 2) + assert.Equal(t, "error1", errs[0].Error()) + assert.Equal(t, "error2", errs[1].Error()) + }) +} + +func TestFailedToValidateAggregatedError_Append(t *testing.T) { + t.Parallel() + + t.Run("nil error", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: nil} + + aggErr.Append(nil) + assert.Empty(t, aggErr.Unwrap()) + }) + + t.Run("one error", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: nil} + + aggErr.Append(errors.New("first error")) + assert.Len(t, aggErr.Unwrap(), 1) + assert.Equal(t, "first error", aggErr.Unwrap()[0].Error()) + }) + + t.Run("two errors", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: nil} + + aggErr.Append(errors.New("first error")) + aggErr.Append(errors.New("second error")) + assert.Len(t, aggErr.Unwrap(), 2) + assert.Equal(t, "first error", aggErr.Unwrap()[0].Error()) + assert.Equal(t, "second error", aggErr.Unwrap()[1].Error()) + }) +} + +func TestFailedToValidateAggregatedError_Error(t *testing.T) { + t.Parallel() + + t.Run("zero errors", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: nil} + assert.Empty(t, aggErr.Error()) + }) + + t.Run("single error", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: []error{errors.New("single error")}} + assert.Equal(t, "single error", aggErr.Error()) + }) + + t.Run("multiple errors", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: []error{errors.New("error1"), errors.New("error2")}} + assert.Equal(t, "aggregated error: error1, error2", aggErr.Error()) + }) +} + +func TestFailedToValidateAggregatedError_Finalize(t *testing.T) { + t.Parallel() + + t.Run("zero errors", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: nil} + require.NoError(t, aggErr.Finalize()) + }) + + t.Run("single error", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: []error{errors.New("single error")}} + + finalizedErr := aggErr.Finalize() + assert.Equal(t, "single error", finalizedErr.Error()) + }) + + t.Run("multiple errors", func(t *testing.T) { + t.Parallel() + + aggErr := FailedToValidateAggregatedError{parent: []error{errors.New("error1"), errors.New("error2")}} + finalizedErr := aggErr.Finalize() + assert.Equal(t, "aggregated error: error1, error2", finalizedErr.Error()) + }) +} + +func TestErrorWrapping(t *testing.T) { + t.Parallel() + + rootErr := errors.New("root cause") + + t.Run("errFailedToGenerateKeys", func(t *testing.T) { + t.Parallel() + + genErr := errFailedToGenerateKeys(rootErr) + require.ErrorIs(t, genErr, rootErr) + }) + + t.Run("errFailedToMarshalValue", func(t *testing.T) { + t.Parallel() + + marshalErr := errFailedToMarshalValue(rootErr) + require.ErrorIs(t, marshalErr, rootErr) + }) + + t.Run("errFailedToComputeHash", func(t *testing.T) { + t.Parallel() + + hashErr := errFailedToComputeHash(rootErr) + require.ErrorIs(t, hashErr, rootErr) + }) + + t.Run("errFailedToGenerateSignature", func(t *testing.T) { + t.Parallel() + + sigErr := errFailedToGenerateSignature(rootErr) + require.ErrorIs(t, sigErr, rootErr) + }) +} diff --git a/integrity/generator.go b/integrity/generator.go new file mode 100644 index 0000000..e195ee4 --- /dev/null +++ b/integrity/generator.go @@ -0,0 +1,100 @@ +package integrity + +import ( + "github.com/tarantool/go-storage/crypto" + "github.com/tarantool/go-storage/hasher" + "github.com/tarantool/go-storage/kv" + "github.com/tarantool/go-storage/marshaller" + "github.com/tarantool/go-storage/namer" +) + +// Generator creates integrity-protected key-value pairs for storage. +type Generator[T any] struct { + namer namer.Namer + marshaller marshaller.TypedMarshaller[T] + hashers map[string]hasher.Hasher + signers map[string]crypto.Signer +} + +// NewGenerator creates a new Generator instance. +func NewGenerator[T any]( + namer namer.Namer, + marshaller marshaller.TypedMarshaller[T], + hashers []hasher.Hasher, + signers []crypto.Signer, +) Generator[T] { + hasherMap := make(map[string]hasher.Hasher) + for _, h := range hashers { + hasherMap[h.Name()] = h + } + + signerMap := make(map[string]crypto.Signer) + for _, s := range signers { + signerMap[s.Name()] = s + } + + return Generator[T]{ + namer: namer, + marshaller: marshaller, + hashers: hasherMap, + signers: signerMap, + } +} + +// Generate creates integrity-protected key-value pairs for the given object. +func (g Generator[T]) Generate(name string, value T) ([]kv.KeyValue, error) { + keys, err := g.namer.GenerateNames(name) + if err != nil { + return nil, errFailedToGenerateKeys(err) + } + + marshalledValue, err := g.marshaller.Marshal(value) + if err != nil { + return nil, errFailedToMarshalValue(err) + } + + results := make([]kv.KeyValue, 0, len(keys)) + + for _, key := range keys { + var valueData []byte + + switch key.Type() { + case namer.KeyTypeValue: + valueData = marshalledValue + + case namer.KeyTypeHash: + hasherInstance, exists := g.hashers[key.Property()] + if !exists { // Shouldn't happen, but just in case. + return nil, errImpossibleHasher(key.Property()) + } + + valueData, err = hasherInstance.Hash(marshalledValue) + if err != nil { + return nil, errFailedToComputeHash(err) + } + + case namer.KeyTypeSignature: + signer, exists := g.signers[key.Property()] + if !exists { // Shouldn't happen, but just in case. + return nil, errImpossibleSigner(key.Property()) + } + + valueData, err = signer.Sign(marshalledValue) + if err != nil { + return nil, errFailedToGenerateSignature(err) + } + + default: + // Shouldn't happen, but just in case. + return nil, errImpossibleKeyType(key.Type().String()) + } + + results = append(results, kv.KeyValue{ + Key: []byte(key.Build()), + Value: valueData, + ModRevision: 0, + }) + } + + return results, nil +} diff --git a/integrity/generator_test.go b/integrity/generator_test.go new file mode 100644 index 0000000..d62a2d2 --- /dev/null +++ b/integrity/generator_test.go @@ -0,0 +1,381 @@ +package integrity_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/go-storage/crypto" + "github.com/tarantool/go-storage/hasher" + "github.com/tarantool/go-storage/integrity" + "github.com/tarantool/go-storage/kv" + "github.com/tarantool/go-storage/marshaller" + "github.com/tarantool/go-storage/namer" +) + +// Test structures. +type SimpleStruct struct { + Name string `yaml:"name"` + Value int `yaml:"value"` +} + +// Mock implementations for hashers and signers only. +type mockHasher struct { + name string + hashFunc func(data []byte) ([]byte, error) + shouldErr bool + errMsg string +} + +func (m *mockHasher) Name() string { return m.name } + +func (m *mockHasher) Hash(data []byte) ([]byte, error) { + if m.shouldErr { + return nil, errors.New(m.errMsg) + } + + if m.hashFunc != nil { + return m.hashFunc(data) + } + + return []byte("mock-hash-" + m.name), nil +} + +type mockSigner struct { + name string + signFunc func(data []byte) ([]byte, error) + shouldErr bool + errMsg string +} + +func (m *mockSigner) Name() string { return m.name } + +func (m *mockSigner) Sign(data []byte) ([]byte, error) { + if m.shouldErr { + return nil, errors.New(m.errMsg) + } + + if m.signFunc != nil { + return m.signFunc(data) + } + + return []byte("mock-signature-" + m.name), nil +} + +// Helper functions for creating mock instances. +func newMockHasher(name string) *mockHasher { + return &mockHasher{ + name: name, + hashFunc: nil, + shouldErr: false, + errMsg: "", + } +} + +func newMockHasherWithError(name, errMsg string) *mockHasher { + return &mockHasher{ + name: name, + hashFunc: nil, + shouldErr: true, + errMsg: errMsg, + } +} + +func newMockSigner(name string) *mockSigner { + return &mockSigner{ + name: name, + signFunc: nil, + shouldErr: false, + errMsg: "", + } +} + +func newMockSignerWithError(name, errMsg string) *mockSigner { + return &mockSigner{ + name: name, + signFunc: nil, + shouldErr: true, + errMsg: errMsg, + } +} + +func TestGeneratorGenerate_SuccessSimpleValue(t *testing.T) { + t.Parallel() + + generator := integrity.NewGenerator[SimpleStruct]( + namer.NewDefaultNamer("test", []string{}, []string{}), + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + nil, + ) + + value := SimpleStruct{Name: "test", Value: 42} + pairs, err := generator.Generate("my-object", value) + + require.NoError(t, err) + require.Len(t, pairs, 1) + + assert.Equal(t, "/test/my-object", string(pairs[0].Key)) + assert.Contains(t, string(pairs[0].Value), "name: test") + assert.Contains(t, string(pairs[0].Value), "value: 42") +} + +func TestGeneratorGenerate_SuccessWithHashes(t *testing.T) { + t.Parallel() + + generator := integrity.NewGenerator[SimpleStruct]( + namer.NewDefaultNamer("test", []string{"sha256", "md5"}, []string{}), + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{ + newMockHasher("sha256"), + newMockHasher("md5"), + }, + nil, + ) + + value := SimpleStruct{Name: "test", Value: 42} + pairs, err := generator.Generate("my-object", value) + + require.NoError(t, err) + require.Len(t, pairs, 3) + + // Find value pair. + var valuePair kv.KeyValue + + for _, pair := range pairs { + if string(pair.Key) == "/test/my-object" { + valuePair = pair + break + } + } + + require.NotEmpty(t, valuePair.Key) + assert.Contains(t, string(valuePair.Value), "name: test") + assert.Contains(t, string(valuePair.Value), "value: 42") + + // Check hash pairs. + hashPairs := 0 + + for _, pair := range pairs { + if string(pair.Key) == "/test/hash/sha256/my-object" { + hashPairs++ + + assert.Equal(t, "mock-hash-sha256", string(pair.Value)) + } + + if string(pair.Key) == "/test/hash/md5/my-object" { + hashPairs++ + + assert.Equal(t, "mock-hash-md5", string(pair.Value)) + } + } + + assert.Equal(t, 2, hashPairs) +} + +func TestGeneratorGenerate_SuccessWithSignatures(t *testing.T) { + t.Parallel() + + generator := integrity.NewGenerator[SimpleStruct]( + namer.NewDefaultNamer("test", []string{}, []string{"rsa", "ecdsa"}), + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + []crypto.Signer{ + newMockSigner("rsa"), + newMockSigner("ecdsa"), + }, + ) + + value := SimpleStruct{Name: "test", Value: 42} + pairs, err := generator.Generate("my-object", value) + + require.NoError(t, err) + require.Len(t, pairs, 3) + + var valuePair kv.KeyValue + + for _, pair := range pairs { + if string(pair.Key) == "/test/my-object" { + valuePair = pair + break + } + } + + require.NotEmpty(t, valuePair.Key) + assert.Contains(t, string(valuePair.Value), "name: test") + assert.Contains(t, string(valuePair.Value), "value: 42") + + sigPairs := 0 + + for _, pair := range pairs { + if string(pair.Key) == "/test/sig/rsa/my-object" { + sigPairs++ + + assert.Equal(t, "mock-signature-rsa", string(pair.Value)) + } + + if string(pair.Key) == "/test/sig/ecdsa/my-object" { + sigPairs++ + + assert.Equal(t, "mock-signature-ecdsa", string(pair.Value)) + } + } + + assert.Equal(t, 2, sigPairs) +} + +func TestGeneratorGenerate_SuccessWithHashesAndSignatures(t *testing.T) { + t.Parallel() + + generator := integrity.NewGenerator[SimpleStruct]( + namer.NewDefaultNamer("test", []string{"sha256"}, []string{"rsa"}), + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{ + newMockHasher("sha256"), + }, + []crypto.Signer{ + newMockSigner("rsa"), + }, + ) + + value := SimpleStruct{Name: "test", Value: 42} + pairs, err := generator.Generate("my-object", value) + + require.NoError(t, err) + require.Len(t, pairs, 3) + + keys := make(map[string]bool) + for _, pair := range pairs { + keys[string(pair.Key)] = true + } + + assert.True(t, keys["/test/my-object"]) + assert.True(t, keys["/test/hash/sha256/my-object"]) + assert.True(t, keys["/test/sig/rsa/my-object"]) +} + +func TestGeneratorGenerate_ErrorHasherNotFound(t *testing.T) { + t.Parallel() + + generator := integrity.NewGenerator[SimpleStruct]( + namer.NewDefaultNamer("test", []string{"sha256", "md5"}, []string{}), + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{ + newMockHasher("sha256"), + }, + nil, + ) + + value := SimpleStruct{Name: "test", Value: 42} + _, err := generator.Generate("my-object", value) + + assert.ErrorAs(t, err, &integrity.ImpossibleError{}) +} + +func TestGeneratorGenerate_ErrorSignerNotFound(t *testing.T) { + t.Parallel() + + generator := integrity.NewGenerator[SimpleStruct]( + namer.NewDefaultNamer("test", []string{}, []string{"rsa", "ecdsa"}), + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + []crypto.Signer{ + newMockSigner("rsa"), + }, + ) + + value := SimpleStruct{Name: "test", Value: 42} + _, err := generator.Generate("my-object", value) + + assert.ErrorAs(t, err, &integrity.ImpossibleError{}) +} + +func TestGeneratorGenerate_ErrorHasherReturnsError(t *testing.T) { + t.Parallel() + + generator := integrity.NewGenerator[SimpleStruct]( + namer.NewDefaultNamer("test", []string{"sha256"}, []string{}), + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{ + newMockHasherWithError("sha256", "hash error"), + }, + nil, + ) + + value := SimpleStruct{Name: "test", Value: 42} + _, err := generator.Generate("my-object", value) + + assert.ErrorAs(t, err, &integrity.FailedToComputeHashError{}) +} + +func TestGeneratorGenerate_ErrorSignerReturnsError(t *testing.T) { + t.Parallel() + + generator := integrity.NewGenerator[SimpleStruct]( + namer.NewDefaultNamer("test", []string{}, []string{"rsa"}), + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + []crypto.Signer{ + newMockSignerWithError("rsa", "sign error"), + }, + ) + + value := SimpleStruct{Name: "test", Value: 42} + _, err := generator.Generate("my-object", value) + + assert.ErrorAs(t, err, &integrity.FailedToGenerateSignatureError{}) +} + +func TestGeneratorGenerate_ErrorInvalidName(t *testing.T) { + t.Parallel() + + generator := integrity.NewGenerator[SimpleStruct]( + namer.NewDefaultNamer("test", []string{}, []string{}), + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + nil, + nil, + ) + + value := SimpleStruct{Name: "test", Value: 42} + + _, err := generator.Generate("", value) + require.ErrorAs(t, err, &integrity.FailedToGenerateKeysError{}) + + _, err = generator.Generate("prefix/", value) + require.ErrorAs(t, err, &integrity.FailedToGenerateKeysError{}) +} + +func TestGeneratorGenerate_OutputStructure(t *testing.T) { + t.Parallel() + + generator := integrity.NewGenerator[SimpleStruct]( + namer.NewDefaultNamer("storage", []string{"sha256"}, []string{"rsa"}), + marshaller.NewTypedYamlMarshaller[SimpleStruct](), + []hasher.Hasher{ + newMockHasher("sha256"), + }, + []crypto.Signer{ + newMockSigner("rsa"), + }, + ) + + value := SimpleStruct{Name: "test-object", Value: 100} + pairs, err := generator.Generate("config/app", value) + + require.NoError(t, err) + require.Len(t, pairs, 3) + + // Verify key patterns. + expectedKeys := map[string]bool{ + "/storage/config/app": true, + "/storage/hash/sha256/config/app": true, + "/storage/sig/rsa/config/app": true, + } + + for _, pair := range pairs { + assert.True(t, expectedKeys[string(pair.Key)], "unexpected key: %s", pair.Key) + assert.NotEmpty(t, pair.Value) + } +} diff --git a/integrity/utils.go b/integrity/utils.go new file mode 100644 index 0000000..2ecadab --- /dev/null +++ b/integrity/utils.go @@ -0,0 +1,3 @@ +package integrity + +func zero[T any]() (out T) { return } //nolint:nonamedreturns diff --git a/integrity/validator.go b/integrity/validator.go new file mode 100644 index 0000000..389845a --- /dev/null +++ b/integrity/validator.go @@ -0,0 +1,197 @@ +package integrity + +import ( + "bytes" + + "github.com/tarantool/go-storage/crypto" + "github.com/tarantool/go-storage/hasher" + "github.com/tarantool/go-storage/kv" + "github.com/tarantool/go-storage/marshaller" + "github.com/tarantool/go-storage/namer" +) + +// Validator verifies integrity-protected key-value pairs. +type Validator[T any] struct { + namer *namer.DefaultNamer + marshaller marshaller.TypedMarshaller[T] + hashers map[string]hasher.Hasher + verifiers map[string]crypto.Verifier +} + +// NewValidator creates a new Validator instance. +func NewValidator[T any]( + namer *namer.DefaultNamer, + marshaller marshaller.TypedMarshaller[T], + hashers []hasher.Hasher, + verifiers []crypto.Verifier, +) Validator[T] { + hasherMap := make(map[string]hasher.Hasher) + for _, h := range hashers { + hasherMap[h.Name()] = h + } + + verifierMap := make(map[string]crypto.Verifier) + for _, v := range verifiers { + verifierMap[v.Name()] = v + } + + return Validator[T]{ + namer: namer, + marshaller: marshaller, + hashers: hasherMap, + verifiers: verifierMap, + } +} + +// 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 +} + +func (v Validator[T]) aggregate(kvs []kv.KeyValue) (map[string][]extendedKV, error) { + out := make(map[string][]extendedKV) + + for _, kvi := range kvs { + parsedKey, err := v.namer.ParseKey(string(kvi.Key)) + if err != nil { + return nil, errFailedToParseKey(err) + } + + if _, ok := out[parsedKey.Name()]; !ok { + out[parsedKey.Name()] = nil + } + + out[parsedKey.Name()] = append(out[parsedKey.Name()], extendedKV{ + parsedKey: parsedKey, + keyValue: kvi, + }) + } + + return out, nil +} + +func (v Validator[T]) constructHashers() map[string]hasher.Hasher { + out := make(map[string]hasher.Hasher) + for _, hash := range v.hashers { + out[hash.Name()] = hash + } + + return out +} + +func (v Validator[T]) constructVerifiers() map[string]crypto.Verifier { + out := make(map[string]crypto.Verifier) + for _, verifier := range v.verifiers { + out[verifier.Name()] = verifier + } + + return out +} + +func (v Validator[T]) validateSingle(name string, kvs []extendedKV) ValidateResult[T] { + expectedHashers := v.constructHashers() + expectedVerifiers := v.constructVerifiers() + + aggregatedError := &FailedToValidateAggregatedError{parent: nil} + + output := ValidateResult[T]{ + Name: name, + HasValue: false, + Value: zero[T](), + Error: nil, + } + + var ( + body []byte + ) + + for _, kvi := range kvs { + if kvi.parsedKey.Type() != namer.KeyTypeValue { + continue + } + + val, err := v.marshaller.Unmarshal(kvi.keyValue.Value) + if err != nil { + output.Error = errFailedToUnmarshal(err) + + return output + } + + output.HasValue = true + output.Value = val + body = kvi.keyValue.Value + } + + for _, kvi := range kvs { + switch kvi.parsedKey.Type() { + case namer.KeyTypeValue: + // Already processed above. + continue + case namer.KeyTypeHash: + hasher, ok := expectedHashers[kvi.parsedKey.Property()] + if !ok { + continue // We've got hasher that we don't expect, skip it. + } + + delete(expectedHashers, kvi.parsedKey.Property()) + + hash, err := hasher.Hash(body) + switch { + case err != nil: + aggregatedError.Append(errFailedToComputeHashWith(kvi.parsedKey.Property(), err)) + case !bytes.Equal(hash, kvi.keyValue.Value): + aggregatedError.Append(errHashMismatch(kvi.parsedKey.Property(), kvi.keyValue.Value, hash)) + } + case namer.KeyTypeSignature: + verifier, ok := expectedVerifiers[kvi.parsedKey.Property()] + if !ok { + continue // We've got hasher that we don't expect, skip it. + } + + delete(expectedVerifiers, kvi.parsedKey.Property()) + + err := verifier.Verify(body, kvi.keyValue.Value) + if err != nil { + aggregatedError.Append(errSignatureVerificationFailed(kvi.parsedKey.Property(), err)) + } + default: + // Shouldn't happen, but just in case. + continue + } + } + + for hasherName := range expectedHashers { + aggregatedError.Append(errHashNotVerifiedMissing(hasherName)) + } + + for verifierName := range expectedVerifiers { + aggregatedError.Append(errSignatureNotVerifiedMissing(verifierName)) + } + + output.Error = aggregatedError.Finalize() + + return output +} + +// Validate verifies integrity-protected key-value pairs and returns the validated value. +func (v Validator[T]) Validate(kvs []kv.KeyValue) ([]ValidateResult[T], error) { + parseKeyResult, err := v.aggregate(kvs) + if err != nil { + return nil, err + } + + out := make([]ValidateResult[T], 0, len(parseKeyResult)) + for name, keys := range parseKeyResult { + out = append(out, v.validateSingle(name, keys)) + } + + return out, nil +} diff --git a/integrity/validator_test.go b/integrity/validator_test.go new file mode 100644 index 0000000..673fe38 --- /dev/null +++ b/integrity/validator_test.go @@ -0,0 +1,749 @@ +package integrity_test + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/go-storage/crypto" + "github.com/tarantool/go-storage/hasher" + "github.com/tarantool/go-storage/integrity" + "github.com/tarantool/go-storage/kv" + "github.com/tarantool/go-storage/marshaller" + "github.com/tarantool/go-storage/namer" +) + +type mockVerifier struct { + name string + verifyErr error +} + +func (m *mockVerifier) Name() string { return m.name } + +func (m *mockVerifier) Verify(_ []byte, _ []byte) error { + return m.verifyErr +} + +type mockTypedMarshaller[T any] struct { + unmarshalErr error +} + +func (m *mockTypedMarshaller[T]) Marshal(_ T) ([]byte, error) { + return []byte("marshalled"), nil +} + +func (m *mockTypedMarshaller[T]) Unmarshal(_ []byte) (T, error) { + var zero T + return zero, m.unmarshalErr +} + +func TestValidatorValidate_Success(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256"}, []string{"rsa"}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + verifiers := []crypto.Verifier{&mockVerifier{name: "rsa", verifyErr: nil}} + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + hashers, + verifiers, + ) + + // Create plain []KeyValue without generator. + value := SimpleStruct{Name: "test", Value: 42} + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha256/my-object"), + Value: []byte{ + 0x86, 0x1c, 0xdf, 0xcd, 0x76, 0x2f, 0x0a, 0x8c, 0xc0, 0xc7, 0xfc, 0x44, 0xcb, 0xfa, 0x5d, 0x29, 0xde, + 0xed, 0x36, 0xa2, 0x5c, 0x73, 0xf7, 0xa4, 0xc6, 0x7a, 0xd6, 0x37, 0xf7, 0x1b, 0xab, 0x39, + }, + ModRevision: 0, + }, + { + Key: []byte("/test/sig/rsa/my-object"), + Value: []byte("mock-signature-rsa"), + ModRevision: 0, + }, + } + + // Validate. + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + 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) +} + +func TestValidatorValidate_MissingHash(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + hashers, + nil, + ) + + // Create KVs with only value key, missing hash key. + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + // Missing hash key: /test/hash/sha256/my-object. + } + + // Should fail because sha256 hash is expected but missing. + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + 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) + require.ErrorAs(t, result.Error, &integrity.ValidationError{}) + require.ErrorContains(t, result.Error, "hash \"sha256\" not verified (missing)") +} + +func TestValidatorValidate_HashMismatch(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + hashers, + nil, + ) + + // Create KVs with corrupted hash value. + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha256/my-object"), + Value: []byte("corrupted-hash"), // Wrong hash. + ModRevision: 0, + }, + } + + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + 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) + require.ErrorAs(t, result.Error, &integrity.ValidationError{}) + require.ErrorContains(t, result.Error, "hash mismatch for \"sha256\"") +} + +func TestValidatorValidate_MultipleObjects(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + hashers, + nil, + ) + + // Create KVs for two objects. + allKVs := []kv.KeyValue{ + // object1. + { + Key: []byte("/test/object1"), + Value: []byte("name: test1\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha256/object1"), + Value: []byte{ + 0xf3, 0x88, 0x82, 0x49, 0x59, 0x8f, 0xbf, 0x4e, 0xcd, 0x8a, 0x47, 0x7a, 0x6b, 0xc3, 0x83, 0xe9, 0xa8, + 0x8f, 0x6c, 0x13, 0xd7, 0x2a, 0x44, 0x86, 0xba, 0x6d, 0xe4, 0xf0, 0xbe, 0x7d, 0x18, 0xa9, + }, + ModRevision: 0, + }, + // object2. + { + Key: []byte("/test/object2"), + Value: []byte("name: test2\nvalue: 100\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha256/object2"), + Value: []byte{ + 0x1c, 0x47, 0x13, 0x01, 0xf9, 0x1b, 0x97, 0x9e, 0xa2, 0x92, 0x3e, 0xd2, 0x95, 0x67, 0x46, 0x6c, 0xad, + 0x09, 0x7d, 0xc6, 0x33, 0xb4, 0x10, 0xac, 0x9d, 0x88, 0xdb, 0xc8, 0xf2, 0xb2, 0x3f, 0x7b, + }, + ModRevision: 0, + }, + } + + validatedResults, err := validator.Validate(allKVs) + require.NoError(t, err) + require.Len(t, validatedResults, 2) + + // Find results by name. + var result1, result2 integrity.ValidateResult[SimpleStruct] + + for _, res := range validatedResults { + switch res.Name { + case "object1": + result1 = res + case "object2": + result2 = res + } + } + + assert.Equal(t, "object1", result1.Name) + assert.True(t, result1.HasValue) + assert.Equal(t, SimpleStruct{Name: "test1", Value: 42}, result1.Value) + 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) + require.NoError(t, result2.Error) +} + +func TestValidatorValidate_PartialSuccess(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + hashers, + nil, + ) + + // Create KVs for two objects, with object2 having corrupted hash. + allKVs := []kv.KeyValue{ + // object1 - valid. + { + Key: []byte("/test/object1"), + Value: []byte("name: test1\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha256/object1"), + Value: []byte{ + 0xf3, 0x88, 0x82, 0x49, 0x59, 0x8f, 0xbf, 0x4e, 0xcd, 0x8a, 0x47, 0x7a, 0x6b, 0xc3, 0x83, 0xe9, 0xa8, + 0x8f, 0x6c, 0x13, 0xd7, 0x2a, 0x44, 0x86, 0xba, 0x6d, 0xe4, 0xf0, 0xbe, 0x7d, 0x18, 0xa9, + }, + ModRevision: 0, + }, + // object2 - corrupted hash. + { + Key: []byte("/test/object2"), + Value: []byte("name: test2\nvalue: 100\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha256/object2"), + Value: []byte("corrupted-hash"), // Wrong hash. + ModRevision: 0, + }, + } + + validatedResults, err := validator.Validate(allKVs) + require.NoError(t, err) + require.Len(t, validatedResults, 2) + + // Find results. + var result1, result2 integrity.ValidateResult[SimpleStruct] + + for _, res := range validatedResults { + switch res.Name { + case "object1": + result1 = res + case "object2": + result2 = res + } + } + + // 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) + 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) + require.ErrorAs(t, result2.Error, &integrity.ValidationError{}) + require.ErrorContains(t, result2.Error, "hash mismatch for \"sha256\"") +} + +func TestValidatorValidate_MissingSignature(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{}, []string{"rsa"}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + verifiers := []crypto.Verifier{&mockVerifier{name: "rsa", verifyErr: nil}} + + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + nil, + verifiers, + ) + + // Create KVs with only value key, missing signature key. + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + // Missing signature key: /test/sig/rsa/my-object . + } + + // Should fail because rsa signature is expected but missing. + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + 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) + require.ErrorAs(t, result.Error, &integrity.ValidationError{}) + require.ErrorContains(t, result.Error, "signature \"rsa\" not verified (missing)") +} + +func TestValidatorValidate_SignatureVerificationError(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{}, []string{"rsa"}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + + // Create verifier that returns error. + verifiers := []crypto.Verifier{&mockVerifier{name: "rsa", verifyErr: assert.AnError}} + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + nil, + verifiers, + ) + + // Create KVs with value and signature. + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/sig/rsa/my-object"), + Value: []byte("mock-signature-rsa"), + ModRevision: 0, + }, + } + + // Should fail because signature verification returns error. + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + 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) + require.ErrorAs(t, result.Error, &integrity.ValidationError{}) + require.ErrorContains(t, result.Error, "signature verification failed for \"rsa\"") +} + +func TestValidatorValidate_HashComputationError(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + + // Validate with failing hasher. + failingHashers := []hasher.Hasher{newMockHasherWithError("sha256", "hash computation failed")} + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + failingHashers, + nil, + ) + + // Create KVs with value and hash. + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha256/my-object"), + Value: []byte{ + 0x86, 0x1c, 0xdf, 0xcd, 0x76, 0x2f, 0x0a, 0x8c, 0xc0, 0xc7, 0xfc, 0x44, 0xcb, 0xfa, 0x5d, 0x29, 0xde, + 0xed, 0x36, 0xa2, 0x5c, 0x73, 0xf7, 0xa4, 0xc6, 0x7a, 0xd6, 0x37, 0xf7, 0x1b, 0xab, 0x39, + }, + ModRevision: 0, + }, + } + + // Should fail because hash computation returns error. + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + 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) + require.ErrorAs(t, result.Error, &integrity.ValidationError{}) + require.ErrorContains(t, result.Error, "failed to calculate hash \"sha256\"") +} + +func TestValidatorValidate_EmptyKVs(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + hashers, + nil, + ) + + // Empty KV list should return empty result. + validatedResults, err := validator.Validate([]kv.KeyValue{}) + require.NoError(t, err) + assert.Empty(t, validatedResults) +} + +func TestValidatorValidate_HasherNotAvailable(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256", "sha1"}, []string{}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + + // Validator only has sha256 hasher, not sha1. + validatorHashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + validatorHashers, + nil, + ) + + // Create KVs with both sha256 and sha1 hashes. + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha256/my-object"), + Value: []byte{ + 0x86, 0x1c, 0xdf, 0xcd, 0x76, 0x2f, 0x0a, 0x8c, 0xc0, 0xc7, 0xfc, 0x44, 0xcb, 0xfa, 0x5d, 0x29, 0xde, + 0xed, 0x36, 0xa2, 0x5c, 0x73, 0xf7, 0xa4, 0xc6, 0x7a, 0xd6, 0x37, 0xf7, 0x1b, 0xab, 0x39, + }, + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha1/my-object"), + Value: []byte("mock-sha1-hash"), + ModRevision: 0, + }, + } + + // Should fail because sha1 hasher is not available. + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + 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) + require.NoError(t, result.Error) +} + +func TestValidatorValidate_VerifierNotAvailable(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{}, []string{"rsa", "ecdsa"}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + + // Validator only has rsa verifier, not ecdsa. + verifiers := []crypto.Verifier{&mockVerifier{name: "rsa", verifyErr: nil}} + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + nil, + verifiers, + ) + + // Create KVs with both rsa and ecdsa signatures. + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/sig/rsa/my-object"), + Value: []byte("mock-signature-rsa"), + ModRevision: 0, + }, + { + Key: []byte("/test/sig/ecdsa/my-object"), + Value: []byte("mock-signature-ecdsa"), + ModRevision: 0, + }, + } + + // Should fail because ecdsa verifier is not available. + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + 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) + require.NoError(t, result.Error) +} + +func TestValidatorValidate_InvalidKeyParsing(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + hashers, + nil, + ) + + // Create KVs with invalid keys that can't be parsed. + invalidKVs := []kv.KeyValue{ + {Key: []byte("invalid/key/format"), Value: []byte("some-value"), ModRevision: 0}, + } + + // Should fail with parse error. + validatedResults, err := validator.Validate(invalidKVs) + require.ErrorAs(t, err, &integrity.ValidationError{}) + assert.Nil(t, validatedResults) +} + +func TestValidatorValidate_MissingValueKey(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + hashers, + nil, + ) + + // Create KVs with only hash key, no value key. + missingValueKVs := []kv.KeyValue{ + {Key: []byte("/test/hash/sha256/my-object"), Value: []byte("some-hash"), ModRevision: 0}, + } + + // Should fail because value key is missing. + validatedResults, err := validator.Validate(missingValueKVs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + result := validatedResults[0] + assert.Equal(t, "my-object", result.Name) + assert.False(t, result.HasValue) + 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") +} + +func TestValidatorValidate_UnmarshalError(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + + // Create a mock marshaller that returns error. + mockMarshaller := &mockTypedMarshaller[SimpleStruct]{ + unmarshalErr: errors.New("invalid yaml format"), + } + + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + + // Create validator with mock marshaller that fails unmarshal. + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + mockMarshaller, + hashers, + nil, + ) + + // Create KVs with value and hash. + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha256/my-object"), + Value: []byte{ + 0x86, 0x1c, 0xdf, 0xcd, 0x76, 0x2f, 0x0a, 0x8c, 0xc0, 0xc7, 0xfc, 0x44, 0xcb, 0xfa, 0x5d, 0x29, 0xde, + 0xed, 0x36, 0xa2, 0x5c, 0x73, 0xf7, 0xa4, 0xc6, 0x7a, 0xd6, 0x37, 0xf7, 0x1b, 0xab, 0x39, + }, + ModRevision: 0, + }, + } + + // Should fail because unmarshal returns error. + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + result := validatedResults[0] + assert.Equal(t, "my-object", result.Name) + assert.False(t, result.HasValue) + require.ErrorAs(t, result.Error, &integrity.ValidationError{}) + require.ErrorContains(t, result.Error, "failed to unmarshal record") +} + +func TestValidatorValidate_HashKeyNotFound(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{"sha256", "sha1"}, []string{}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + hashers := []hasher.Hasher{hasher.NewSHA256Hasher(), hasher.NewSHA1Hasher()} + + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + hashers, + nil, + ) + + // Create KVs with sha256 hash but missing sha1 hash. + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/hash/sha256/my-object"), + Value: []byte{ + 0x86, 0x1c, 0xdf, 0xcd, 0x76, 0x2f, 0x0a, 0x8c, 0xc0, 0xc7, 0xfc, 0x44, 0xcb, 0xfa, 0x5d, 0x29, 0xde, + 0xed, 0x36, 0xa2, 0x5c, 0x73, 0xf7, 0xa4, 0xc6, 0x7a, 0xd6, 0x37, 0xf7, 0x1b, 0xab, 0x39, + }, + ModRevision: 0, + }, + // Missing sha1 hash key: /test/hash/sha1/my-object . + } + + // Should fail because sha1 hash key is missing. + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + 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) + require.ErrorAs(t, result.Error, &integrity.ValidationError{}) + require.ErrorContains(t, result.Error, "hash \"sha1\" not verified (missing)") +} + +func TestValidatorValidate_SignatureKeyNotFound(t *testing.T) { + t.Parallel() + + namerInstance := namer.NewDefaultNamer("test", []string{}, []string{"rsa", "ecdsa"}) + marshallerInstance := marshaller.NewTypedYamlMarshaller[SimpleStruct]() + + // Validator has both verifiers. + verifiers := []crypto.Verifier{ + &mockVerifier{name: "rsa", verifyErr: nil}, + &mockVerifier{name: "ecdsa", verifyErr: nil}, + } + validator := integrity.NewValidator[SimpleStruct]( + namerInstance, + marshallerInstance, + nil, + verifiers, + ) + + // Create KVs with rsa signature but missing ecdsa signature. + kvs := []kv.KeyValue{ + { + Key: []byte("/test/my-object"), + Value: []byte("name: test\nvalue: 42\n"), + ModRevision: 0, + }, + { + Key: []byte("/test/sig/rsa/my-object"), + Value: []byte("mock-signature-rsa"), + ModRevision: 0, + }, + // Missing ecdsa signature key: /test/sig/ecdsa/my-object . + } + + // Should fail because ecdsa signature key is missing. + validatedResults, err := validator.Validate(kvs) + require.NoError(t, err) + require.Len(t, validatedResults, 1) + + 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) + require.ErrorAs(t, result.Error, &integrity.ValidationError{}) + require.ErrorContains(t, result.Error, "signature \"ecdsa\" not verified (missing)") +} diff --git a/marshaller/error.go b/marshaller/error.go new file mode 100644 index 0000000..0a9b31e --- /dev/null +++ b/marshaller/error.go @@ -0,0 +1,51 @@ +package marshaller + +import ( + "fmt" +) + +// MarshalError represents an error when marshalling fails. +type MarshalError struct { + parent error +} + +func errMarshal(parent error) error { + if parent == nil { + return nil + } + + return MarshalError{parent: parent} +} + +// Unwrap returns the underlying error that caused the marshalling failure. +func (e MarshalError) Unwrap() error { + return e.parent +} + +// Error returns a string representation of the marshalling error. +func (e MarshalError) Error() string { + return fmt.Sprintf("Failed to marshal: %s", e.parent) +} + +// UnmarshalError represents an error when unmarshalling fails. +type UnmarshalError struct { + parent error +} + +func errUnmarshal(parent error) error { + if parent == nil { + return nil + } + + return UnmarshalError{parent: parent} +} + +// Unwrap returns the underlying error that caused the unmarshalling failure. +func (e UnmarshalError) Unwrap() error { + return e.parent +} + +// Error returns a string representation of the unmarshalling error. +func (e UnmarshalError) Error() string { + return fmt.Sprintf("Failed to unmarshal: %s", e.parent) +} diff --git a/marshaller/error_test.go b/marshaller/error_test.go new file mode 100644 index 0000000..dfc20c9 --- /dev/null +++ b/marshaller/error_test.go @@ -0,0 +1,124 @@ +// Package marshaller provides types for marshalling operations. +package marshaller //nolint:testpackage + +import ( + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMarshalError_Error(t *testing.T) { + t.Parallel() + + parentErr := errors.New("yaml marshal error") + err := MarshalError{parent: parentErr} + assert.Equal(t, "Failed to marshal: yaml marshal error", err.Error()) +} + +func TestMarshalError_Unwrap(t *testing.T) { + t.Parallel() + + parentErr := errors.New("yaml marshal error") + err := MarshalError{parent: parentErr} + assert.Equal(t, parentErr, err.Unwrap()) +} + +func Test_errMarshal(t *testing.T) { + t.Parallel() + + t.Run("with parent error", func(t *testing.T) { + t.Parallel() + + parentErr := errors.New("yaml marshal error") + err := errMarshal(parentErr) + require.Error(t, err) + assert.Equal(t, "Failed to marshal: yaml marshal error", err.Error()) + + var marshalErr MarshalError + require.ErrorAs(t, err, &marshalErr) + assert.Equal(t, parentErr, marshalErr.Unwrap()) + }) + + t.Run("with nil parent error", func(t *testing.T) { + t.Parallel() + + err := errMarshal(nil) + require.NoError(t, err) + }) +} + +func TestUnmarshalError_Error(t *testing.T) { + t.Parallel() + + parentErr := errors.New("yaml unmarshal error") + err := UnmarshalError{parent: parentErr} + assert.Equal(t, "Failed to unmarshal: yaml unmarshal error", err.Error()) +} + +func TestUnmarshalError_Unwrap(t *testing.T) { + t.Parallel() + + parentErr := errors.New("yaml unmarshal error") + err := UnmarshalError{parent: parentErr} + assert.Equal(t, parentErr, err.Unwrap()) +} + +func Test_errUnmarshal(t *testing.T) { + t.Parallel() + + t.Run("with parent error", func(t *testing.T) { + t.Parallel() + + parentErr := errors.New("yaml unmarshal error") + err := errUnmarshal(parentErr) + require.Error(t, err) + assert.Equal(t, "Failed to unmarshal: yaml unmarshal error", err.Error()) + + var unmarshalErr UnmarshalError + require.ErrorAs(t, err, &unmarshalErr) + assert.Equal(t, parentErr, unmarshalErr.Unwrap()) + }) + + t.Run("with nil parent error", func(t *testing.T) { + t.Parallel() + + err := errUnmarshal(nil) + require.NoError(t, err) + }) +} + +func TestErrorWrapping(t *testing.T) { + t.Parallel() + + rootErr := errors.New("root cause") + + t.Run("MarshalError wraps parent", func(t *testing.T) { + t.Parallel() + + marshalErr := MarshalError{parent: rootErr} + require.ErrorIs(t, marshalErr, rootErr) + }) + + t.Run("UnmarshalError wraps parent", func(t *testing.T) { + t.Parallel() + + unmarshalErr := UnmarshalError{parent: rootErr} + require.ErrorIs(t, unmarshalErr, rootErr) + }) + + t.Run("errMarshal wraps parent", func(t *testing.T) { + t.Parallel() + + err := errMarshal(rootErr) + require.ErrorIs(t, err, rootErr) + }) + + t.Run("errUnmarshal wraps parent", func(t *testing.T) { + t.Parallel() + + err := errUnmarshal(rootErr) + require.ErrorIs(t, err, rootErr) + }) +} diff --git a/marshaller/interface.go b/marshaller/interface.go new file mode 100644 index 0000000..11d2a7a --- /dev/null +++ b/marshaller/interface.go @@ -0,0 +1,24 @@ +package marshaller + +// Marshaller - serialization by default (JSON/Protobuf/etc), +// implements one time for all objects. +// Required for `integrity.Storage` to set marshalling format for any type object +// and as recommendation for developers of `Storage` wrappers. +type Marshaller interface { + Marshal(data any) ([]byte, error) + Unmarshal(data []byte, out any) error +} + +// Marshallable - custom object serialization, implements for each object. +// Required for `integrity.Storage` type to set marshalling format to specific object +// and as recommendation for developers of `Storage` wrappers. +type Marshallable interface { + Marshal() ([]byte, error) + Unmarshal(data []byte) error +} + +// TypedMarshaller is a generic interface for typed marshalling operations. +type TypedMarshaller[T any] interface { + Marshal(data T) ([]byte, error) + Unmarshal(data []byte) (T, error) +} diff --git a/marshaller/marshaller.go b/marshaller/marshaller.go deleted file mode 100644 index 9ec6f14..0000000 --- a/marshaller/marshaller.go +++ /dev/null @@ -1,59 +0,0 @@ -// Package marshaller represent interface to transformation. -package marshaller - -import ( - "errors" - - "gopkg.in/yaml.v3" -) - -// ErrMarshall is returned if Marshalling failed. -var ErrMarshall = errors.New("failed to marshal") - -// ErrUnmarshall is returned if Unmarshalling failed. -var ErrUnmarshall = errors.New("failed to unmarshal") - -// DefaultMarshaller - serialization by default (JSON/Protobuf/etc), -// implements one time for all objects. -// Required for `integrity.Storage` to set marshalling format for any type object -// and as recommendation for developers of `Storage` wrappers. -type DefaultMarshaller interface { //nolint:iface - Marshal(data any) ([]byte, error) - Unmarshal(data []byte, out any) error -} - -// Marshallable - custom object serialization, implements for each object. -// Required for `integrity.Storage` type to set marshalling format to specific object -// and as recommendation for developers of `Storage` wrappers. -type Marshallable interface { //nolint:iface - Marshal(data any) ([]byte, error) - Unmarshal(data []byte, out any) error -} - -// YAMLMarshaller struct represent realization. -type YAMLMarshaller struct{} - -// NewYamlMarshaller creates new NewYamlMarshaller object. -func NewYamlMarshaller() Marshallable { - return YAMLMarshaller{} -} - -// Marshal implements interface. -func (m YAMLMarshaller) Marshal(data any) ([]byte, error) { - marshalled, err := yaml.Marshal(data) - if err != nil { - return []byte{}, ErrMarshall - } - - return marshalled, nil -} - -// Unmarshal implements interface. -func (m YAMLMarshaller) Unmarshal(data []byte, out any) error { - err := yaml.Unmarshal(data, &out) - if err != nil { - return ErrUnmarshall - } - - return nil -} diff --git a/marshaller/marshaller_test.go b/marshaller/marshaller_test.go deleted file mode 100644 index de93bdd..0000000 --- a/marshaller/marshaller_test.go +++ /dev/null @@ -1,47 +0,0 @@ -package marshaller_test - -import ( - "testing" - - "github.com/stretchr/testify/require" - "github.com/tarantool/go-storage/marshaller" -) - -type YamlStruct struct { - Title string `yaml:"title"` - Link string `yaml:"link"` -} - -func TestMarshal(t *testing.T) { - t.Parallel() - - marshaller := marshaller.NewYamlMarshaller() - - data, err := marshaller.Marshal([]byte{123, 123}) - require.NoError(t, err) - require.NotNil(t, data) -} - -func TestUnmarshal(t *testing.T) { - t.Parallel() - - marshaller := marshaller.NewYamlMarshaller() - - var unmarshaledYaml YamlStruct - - validYaml := ` -title: "Link" -link: "https://some.link" -` - - err := marshaller.Unmarshal([]byte(validYaml), &unmarshaledYaml) - require.NoError(t, err) - - invalidYaml := ` -TITLE: 123 - Link: true -` - - err = marshaller.Unmarshal([]byte(invalidYaml), &unmarshaledYaml) - require.Error(t, err) -} diff --git a/marshaller/typed.go b/marshaller/typed.go new file mode 100644 index 0000000..84040cd --- /dev/null +++ b/marshaller/typed.go @@ -0,0 +1,35 @@ +package marshaller + +import ( + "gopkg.in/yaml.v3" +) + +// TypedYamlMarshaller is a generic YAML marshaller for typed objects. +type TypedYamlMarshaller[T any] struct{} + +// NewTypedYamlMarshaller creates a new TypedYamlMarshaller for the specified type. +func NewTypedYamlMarshaller[T any]() TypedYamlMarshaller[T] { + return TypedYamlMarshaller[T]{} +} + +// Marshal serializes the typed data to YAML format. +func (m TypedYamlMarshaller[T]) Marshal(data T) ([]byte, error) { + marshalled, err := yaml.Marshal(data) + if err != nil { + return []byte{}, errMarshal(err) + } + + return marshalled, nil +} + +// Unmarshal deserializes YAML data into a typed object. +func (m TypedYamlMarshaller[T]) Unmarshal(data []byte) (T, error) { + var out T + + err := yaml.Unmarshal(data, &out) + if err != nil { + return zero[T](), errUnmarshal(err) + } + + return out, nil +} diff --git a/marshaller/typed_test.go b/marshaller/typed_test.go new file mode 100644 index 0000000..2d115e9 --- /dev/null +++ b/marshaller/typed_test.go @@ -0,0 +1,243 @@ +package marshaller_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/tarantool/go-storage/marshaller" +) + +type TestStruct struct { + Name string `yaml:"name"` + Value int `yaml:"value"` + Tags []string `yaml:"tags,omitempty"` +} + +type NestedStruct struct { + ID int `yaml:"id"` + Data TestStruct `yaml:"data"` + Active bool `yaml:"active"` +} + +func TestTypedYamlMarshaller_New(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[TestStruct]() + require.NotNil(t, marsh) +} + +func TestTypedYamlMarshaller_Marshal_Success(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[TestStruct]() + + data := TestStruct{ + Name: "test", + Value: 42, + Tags: []string{"tag1", "tag2"}, + } + + result, err := marsh.Marshal(data) + require.NoError(t, err) + require.NotEmpty(t, result) + + expectedYaml := `name: test +value: 42 +tags: + - tag1 + - tag2 +` + require.YAMLEq(t, expectedYaml, string(result)) +} + +func TestTypedYamlMarshaller_Marshal_EmptyStruct(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[TestStruct]() + + data := TestStruct{ + Name: "", + Value: 0, + Tags: nil, + } + + result, err := marsh.Marshal(data) + require.NoError(t, err) + require.NotEmpty(t, result) + + expectedYaml := `name: "" +value: 0 +` + require.YAMLEq(t, expectedYaml, string(result)) +} + +func TestTypedYamlMarshaller_Marshal_NestedStruct(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[NestedStruct]() + + data := NestedStruct{ + ID: 1, + Data: TestStruct{ + Name: "nested", + Value: 100, + Tags: nil, + }, + Active: true, + } + + result, err := marsh.Marshal(data) + require.NoError(t, err) + require.NotEmpty(t, result) + + expectedYaml := `id: 1 +data: + name: nested + value: 100 +active: true +` + require.YAMLEq(t, expectedYaml, string(result)) +} + +func TestTypedYamlMarshaller_Unmarshal_Success(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[TestStruct]() + + yamlData := []byte(`name: test +value: 42 +tags: + - tag1 + - tag2 +`) + + result, err := marsh.Unmarshal(yamlData) + require.NoError(t, err) + + expected := TestStruct{ + Name: "test", + Value: 42, + Tags: []string{"tag1", "tag2"}, + } + require.Equal(t, expected, result) +} + +func TestTypedYamlMarshaller_Unmarshal_EmptyYaml(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[TestStruct]() + + yamlData := []byte(``) + + result, err := marsh.Unmarshal(yamlData) + require.NoError(t, err) + + expected := TestStruct{ + Name: "", + Value: 0, + Tags: nil, + } + require.Equal(t, expected, result) +} + +func TestTypedYamlMarshaller_Unmarshal_NestedStruct(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[NestedStruct]() + + yamlData := []byte(`id: 1 +data: + name: nested + value: 100 +active: true +`) + + result, err := marsh.Unmarshal(yamlData) + require.NoError(t, err) + + expected := NestedStruct{ + ID: 1, + Data: TestStruct{ + Name: "nested", + Value: 100, + Tags: nil, + }, + Active: true, + } + require.Equal(t, expected, result) +} + +func TestTypedYamlMarshaller_Unmarshal_InvalidYaml(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[TestStruct]() + + invalidYaml := []byte(`name: test +value: not_a_number +`) + + _, err := marsh.Unmarshal(invalidYaml) + require.Error(t, err) + require.Contains(t, err.Error(), "Failed to unmarshal") +} + +func TestTypedYamlMarshaller_RoundTrip(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[TestStruct]() + + original := TestStruct{ + Name: "roundtrip", + Value: 99, + Tags: []string{"a", "b", "c"}, + } + + marshaled, err := marsh.Marshal(original) + require.NoError(t, err) + require.NotEmpty(t, marshaled) + + unmarshaled, err := marsh.Unmarshal(marshaled) + require.NoError(t, err) + + require.Equal(t, original, unmarshaled) +} + +func TestTypedYamlMarshaller_WithPrimitiveType(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[int]() + + yamlData := []byte(`42`) + + result, err := marsh.Unmarshal(yamlData) + require.NoError(t, err) + require.Equal(t, 42, result) + + marshaled, err := marsh.Marshal(100) + require.NoError(t, err) + require.Equal(t, "100\n", string(marshaled)) +} + +func TestTypedYamlMarshaller_WithSliceType(t *testing.T) { + t.Parallel() + + marsh := marshaller.NewTypedYamlMarshaller[[]string]() + + yamlData := []byte(`- item1 +- item2 +- item3 +`) + + result, err := marsh.Unmarshal(yamlData) + require.NoError(t, err) + require.Equal(t, []string{"item1", "item2", "item3"}, result) + + original := []string{"a", "b", "c"} + marshaled, err := marsh.Marshal(original) + require.NoError(t, err) + + unmarshaled, err := marsh.Unmarshal(marshaled) + require.NoError(t, err) + require.Equal(t, original, unmarshaled) +} diff --git a/marshaller/utils.go b/marshaller/utils.go new file mode 100644 index 0000000..c19e93e --- /dev/null +++ b/marshaller/utils.go @@ -0,0 +1,3 @@ +package marshaller + +func zero[T any]() (out T) { return } //nolint:nonamedreturns diff --git a/namer/key.go b/namer/key.go index 8b2fd4e..31a921a 100644 --- a/namer/key.go +++ b/namer/key.go @@ -1,5 +1,9 @@ package namer +import ( + "fmt" +) + // KeyType represents key types. type KeyType int @@ -12,6 +16,19 @@ const ( KeyTypeSignature ) +func (t KeyType) String() string { + switch t { + case KeyTypeValue: + return "KeyTypeValue" + case KeyTypeHash: + return "KeyTypeHash" + case KeyTypeSignature: + return "KeyTypeSignature" + default: + return fmt.Sprintf("KeyType[%d]", t) + } +} + // Key defines the minimal interface required by keys. type Key interface { Name() string // Get object name. diff --git a/namer/key_test.go b/namer/key_test.go index 276449c..7be9303 100644 --- a/namer/key_test.go +++ b/namer/key_test.go @@ -50,3 +50,52 @@ func TestDefaultKey_Property(t *testing.T) { a := namer.NewDefaultKey(defaultName, defaultType, defaultProperty, defaultRaw) assert.Equal(t, defaultProperty, a.Property()) } + +func TestKeyType_String(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + keyType namer.KeyType + expected string + }{ + { + name: "KeyTypeValue", + keyType: namer.KeyTypeValue, + expected: "KeyTypeValue", + }, + { + name: "KeyTypeHash", + keyType: namer.KeyTypeHash, + expected: "KeyTypeHash", + }, + { + name: "KeyTypeSignature", + keyType: namer.KeyTypeSignature, + expected: "KeyTypeSignature", + }, + { + name: "Unknown key type zero", + keyType: 0, + expected: "KeyType[0]", + }, + { + name: "Unknown key type negative", + keyType: -1, + expected: "KeyType[-1]", + }, + { + name: "Unknown key type positive", + keyType: 100, + expected: "KeyType[100]", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tt.expected, tt.keyType.String()) + }) + } +} diff --git a/namer/namer.go b/namer/namer.go index 30dc20f..7a038b7 100644 --- a/namer/namer.go +++ b/namer/namer.go @@ -12,6 +12,13 @@ const ( maxKeyParts = 3 ) +// Namer defines the interface for generating and parsing storage key names. +type Namer interface { + GenerateNames(name string) ([]Key, error) + ParseKey(name string) (DefaultKey, error) + ParseKeys(names []string, ignoreError bool) (Results, error) +} + // DefaultNamer represents default namer. type DefaultNamer struct { prefix string // Key prefix (e.g., "storage/").