From 28bbd84ca0a445a9a7f915fc5789ea6a45c6ebcf Mon Sep 17 00:00:00 2001 From: Alexey Potapenko Date: Tue, 18 Nov 2025 10:14:27 +0300 Subject: [PATCH 1/2] api: implement namer Closes TNTP-4190 --- Makefile | 2 +- crypto/interfaces.go | 2 +- crypto/{rsa.go => rsapss.go} | 8 +- crypto/{rsa_test.go => rsapss_test.go} | 1 + hasher/hasher_test.go | 8 +- kv/kv.go | 1 - marshaller/marshaller.go | 8 +- namer/error.go | 39 ++++ namer/error_test.go | 23 +++ namer/key.go | 59 ++++++ namer/key_test.go | 52 +++++ namer/namer.go | 186 ++++++++++++++--- namer/namer_test.go | 263 +++++++++++++++++++++++++ namer/results.go | 68 +++++++ namer/results_test.go | 172 ++++++++++++++++ 15 files changed, 851 insertions(+), 41 deletions(-) rename crypto/{rsa.go => rsapss.go} (89%) rename crypto/{rsa_test.go => rsapss_test.go} (99%) create mode 100644 namer/error.go create mode 100644 namer/error_test.go create mode 100644 namer/key.go create mode 100644 namer/key_test.go create mode 100644 namer/namer_test.go create mode 100644 namer/results.go create mode 100644 namer/results_test.go diff --git a/Makefile b/Makefile index 8cb2760..d497c5b 100644 --- a/Makefile +++ b/Makefile @@ -38,7 +38,7 @@ coveralls-deps: .PHONY: lint-deps lint-deps: @echo "Installing lint deps" - @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.5.0 + @go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7.2 .PHONY: lint lint: lint-deps diff --git a/crypto/interfaces.go b/crypto/interfaces.go index 0dcbab3..e42a9d4 100644 --- a/crypto/interfaces.go +++ b/crypto/interfaces.go @@ -1,4 +1,4 @@ -// Package crypto implements crypto interfaces. +// Package crypto implements verification interfaces. package crypto // Signer implements high-level API for package signing. diff --git a/crypto/rsa.go b/crypto/rsapss.go similarity index 89% rename from crypto/rsa.go rename to crypto/rsapss.go index 0f2234e..fcc2b51 100644 --- a/crypto/rsa.go +++ b/crypto/rsapss.go @@ -1,4 +1,4 @@ -package crypto +package crypto //nolint:revive import ( "crypto" @@ -29,12 +29,12 @@ func NewRSAPSS(privKey rsa.PrivateKey, pubKey rsa.PublicKey) RSAPSS { } // Name implements SignerVerifier interface. -func (r *RSAPSS) Name() string { +func (r RSAPSS) Name() string { return "RSASSA-PSS" } // Sign generates SHA-256 digest and signs it using RSASSA-PSS. -func (r *RSAPSS) Sign(data []byte) ([]byte, error) { +func (r RSAPSS) Sign(data []byte) ([]byte, error) { digest, err := r.hasher.Hash(data) if err != nil { return []byte{}, fmt.Errorf("failed to get hash: %w", err) @@ -52,7 +52,7 @@ func (r *RSAPSS) Sign(data []byte) ([]byte, error) { } // Verify compares data with signature. -func (r *RSAPSS) Verify(data []byte, signature []byte) error { +func (r RSAPSS) Verify(data []byte, signature []byte) error { digest, err := r.hasher.Hash(data) if err != nil { return fmt.Errorf("failed to get hash: %w", err) diff --git a/crypto/rsa_test.go b/crypto/rsapss_test.go similarity index 99% rename from crypto/rsa_test.go rename to crypto/rsapss_test.go index 3645f35..327b897 100644 --- a/crypto/rsa_test.go +++ b/crypto/rsapss_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/stretchr/testify/require" + "github.com/tarantool/go-storage/crypto" ) diff --git a/hasher/hasher_test.go b/hasher/hasher_test.go index a334dff..97ccab5 100644 --- a/hasher/hasher_test.go +++ b/hasher/hasher_test.go @@ -61,9 +61,9 @@ func TestSHA256Hasher(t *testing.T) { t.Parallel() tests := []struct { - name string - in []byte - out string + name string + in []byte + expected string }{ {"empty", []byte(""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, {"abc", []byte("abc"), "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"}, @@ -77,7 +77,7 @@ func TestSHA256Hasher(t *testing.T) { result, _ := h.Hash(test.in) - assert.Equal(t, test.out, hex.EncodeToString(result)) + assert.Equal(t, test.expected, hex.EncodeToString(result)) }) } } diff --git a/kv/kv.go b/kv/kv.go index 178d9a2..6a5e5cb 100644 --- a/kv/kv.go +++ b/kv/kv.go @@ -9,7 +9,6 @@ type KeyValue struct { Key []byte // Value is the serialized representation of the value. Value []byte - // ModRevision is the revision number of the last modification to this key. ModRevision int64 } diff --git a/marshaller/marshaller.go b/marshaller/marshaller.go index 83702b4..9ec6f14 100644 --- a/marshaller/marshaller.go +++ b/marshaller/marshaller.go @@ -17,7 +17,7 @@ var ErrUnmarshall = errors.New("failed to unmarshal") // 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 { +type DefaultMarshaller interface { //nolint:iface Marshal(data any) ([]byte, error) Unmarshal(data []byte, out any) error } @@ -25,8 +25,8 @@ type DefaultMarshaller interface { // 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) +type Marshallable interface { //nolint:iface + Marshal(data any) ([]byte, error) Unmarshal(data []byte, out any) error } @@ -34,7 +34,7 @@ type Marshallable interface { type YAMLMarshaller struct{} // NewYamlMarshaller creates new NewYamlMarshaller object. -func NewYamlMarshaller() YAMLMarshaller { +func NewYamlMarshaller() Marshallable { return YAMLMarshaller{} } diff --git a/namer/error.go b/namer/error.go new file mode 100644 index 0000000..87e66a9 --- /dev/null +++ b/namer/error.go @@ -0,0 +1,39 @@ +package namer + +import ( + "fmt" +) + +// InvalidKeyError represents an error for invalid key format. +type InvalidKeyError struct { + Key string + Problem string +} + +func (e InvalidKeyError) Error() string { + return fmt.Sprintf("invalid key '%s': %s", e.Key, e.Problem) +} + +func errInvalidKey(key string, problem string) error { + return InvalidKeyError{ + Key: key, + Problem: problem, + } +} + +// InvalidNameError represents an error for invalid name format. +type InvalidNameError struct { + Name string + Problem string +} + +func (e InvalidNameError) Error() string { + return fmt.Sprintf("invalid name '%s': %s", e.Name, e.Problem) +} + +func errInvalidName(name string, problem string) error { + return InvalidNameError{ + Name: name, + Problem: problem, + } +} diff --git a/namer/error_test.go b/namer/error_test.go new file mode 100644 index 0000000..8ac961b --- /dev/null +++ b/namer/error_test.go @@ -0,0 +1,23 @@ +package namer_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/tarantool/go-storage/namer" +) + +func TestInvalidNameError_Error(t *testing.T) { + t.Parallel() + + err := namer.InvalidNameError{Name: "name", Problem: "problem"} + assert.Equal(t, "invalid name 'name': problem", err.Error()) +} + +func TestInvalidKeyError_Error(t *testing.T) { + t.Parallel() + + err := namer.InvalidKeyError{Key: "name", Problem: "problem"} + assert.Equal(t, "invalid key 'name': problem", err.Error()) +} diff --git a/namer/key.go b/namer/key.go new file mode 100644 index 0000000..8b2fd4e --- /dev/null +++ b/namer/key.go @@ -0,0 +1,59 @@ +package namer + +// KeyType represents key types. +type KeyType int + +const ( + // KeyTypeValue represents data type. + KeyTypeValue KeyType = iota + 1 + // KeyTypeHash represents hash of the data type. + KeyTypeHash + // KeyTypeSignature represents signature of the data type. + KeyTypeSignature +) + +// Key defines the minimal interface required by keys. +type Key interface { + Name() string // Get object name. + Type() KeyType // Get key type. + Property() string // Get metadata (e.g., algorithm version). + Build() string // Reconstruct raw key string. +} + +// DefaultKey implements default realization. +type DefaultKey struct { + name string // Object identifier. + keyType KeyType // Type of object (hash/signature/value). + property string // Additional metadata (version/algorithm). + raw string // Raw key string. +} + +// NewDefaultKey returns new Key object. +func NewDefaultKey(name string, keytype KeyType, property string, raw string) DefaultKey { + return DefaultKey{ + name: name, + keyType: keytype, + property: property, + raw: raw, + } +} + +// Name returns name of the key. +func (k DefaultKey) Name() string { + return k.name +} + +// Type returns type of the key. +func (k DefaultKey) Type() KeyType { + return k.keyType +} + +// Property returns property of the key. +func (k DefaultKey) Property() string { + return k.property +} + +// Build reconstructs key string. +func (k DefaultKey) Build() string { + return k.raw +} diff --git a/namer/key_test.go b/namer/key_test.go new file mode 100644 index 0000000..276449c --- /dev/null +++ b/namer/key_test.go @@ -0,0 +1,52 @@ +package namer_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/go-storage/namer" +) + +const ( + defaultName = "all" + defaultType = namer.KeyTypeHash + defaultProperty = "sha256" + defaultRaw = "/config/hash/sha256/all" +) + +func TestNewDefaultKey(t *testing.T) { + t.Parallel() + + a := namer.NewDefaultKey(defaultName, defaultType, defaultProperty, defaultRaw) + require.NotEmpty(t, a) +} + +func TestDefaultKey_Build(t *testing.T) { + t.Parallel() + + a := namer.NewDefaultKey(defaultName, defaultType, defaultProperty, defaultRaw) + require.Equal(t, defaultRaw, a.Build()) +} + +func TestDefaultKey_Name(t *testing.T) { + t.Parallel() + + a := namer.NewDefaultKey(defaultName, defaultType, defaultProperty, defaultRaw) + assert.Equal(t, defaultName, a.Name()) +} + +func TestDefaultKey_Type(t *testing.T) { + t.Parallel() + + a := namer.NewDefaultKey(defaultName, defaultType, defaultProperty, defaultRaw) + assert.Equal(t, defaultType, a.Type()) +} + +func TestDefaultKey_Property(t *testing.T) { + t.Parallel() + + a := namer.NewDefaultKey(defaultName, defaultType, defaultProperty, defaultRaw) + assert.Equal(t, defaultProperty, a.Property()) +} diff --git a/namer/namer.go b/namer/namer.go index f272a00..30dc20f 100644 --- a/namer/namer.go +++ b/namer/namer.go @@ -2,41 +2,175 @@ package namer import ( - "github.com/tarantool/go-storage/kv" + "fmt" + "strings" ) -// KeyType represents key types. -type KeyType int - const ( - // KeyTypeValue represents data type. - KeyTypeValue KeyType = iota + 1 - // KeyTypeHash represents hash of the data type. - KeyTypeHash - // KeyTypeSignature represents signature of the data type. - KeyTypeSignature + hashName = "hash" + signatureName = "sig" + maxKeyParts = 3 ) -// Key implements internal realization. -type Key struct { - Name string // Object identificator. - Type KeyType // Type of the object. - Property string // Additional information (version/algorithm). +// DefaultNamer represents default namer. +type DefaultNamer struct { + prefix string // Key prefix (e.g., "storage/"). + hashNames []string + sigNames []string +} + +// NewDefaultNamer returns new DefaultNamer object with hash/signature names configuration. +func NewDefaultNamer(prefix string, hashNames []string, sigNames []string) *DefaultNamer { + return &DefaultNamer{ + prefix: strings.Trim(prefix, "/"), + hashNames: hashNames, + sigNames: sigNames, + } +} + +// GenerateNames all keys for an object name. +func (n *DefaultNamer) GenerateNames(name string) ([]Key, error) { + switch { + case name == "": + return nil, errInvalidName(name, "should not be empty") + case strings.HasSuffix(name, "/"): + return nil, errInvalidName(name, "should not be prefix") + } + + name = strings.TrimPrefix(name, "/") + + out := make([]Key, 0, len(n.hashNames)+len(n.sigNames)+1) + + out = append(out, + NewDefaultKey( + name, + KeyTypeValue, + "", + fmt.Sprintf("/%s/%s", n.prefix, name), + )) + + for _, hash := range n.hashNames { + out = append(out, + NewDefaultKey( + name, + KeyTypeHash, + hash, + fmt.Sprintf("/%s/%s/%s/%s", n.prefix, hashName, hash, name), + ), + ) + } + + for _, sig := range n.sigNames { + out = append(out, + NewDefaultKey( + name, + KeyTypeSignature, + sig, + fmt.Sprintf("/%s/%s/%s/%s", n.prefix, signatureName, sig, name), + ), + ) + } + + return out, nil +} + +// ParseKey parses a raw key name into a structured DefaultKey. +func (n *DefaultNamer) ParseKey(name string) (DefaultKey, error) { + originalName := name + + name, found := strings.CutPrefix(strings.TrimPrefix(name, "/"), n.prefix) + if !found { + return DefaultKey{}, errInvalidKey(originalName, "prefix not found") + } + + name = strings.TrimPrefix(name, "/") + + nameParts := strings.SplitN(name, "/", maxKeyParts) + + switch { + case len(nameParts) == 0 || len(nameParts) > maxKeyParts: + panic("illegal state") // Unreachable. + + case nameParts[0] == signatureName: + return n.parseSignatureKey(nameParts, originalName) + case nameParts[0] == hashName: + return n.parseHashKey(nameParts, originalName) + default: + return n.parseValueKey(name, originalName) + } } -// Namer represents keys naming strategy. -type Namer interface { - GenerateNames(name string) []string // Object's keys generation. - ParseNames(names []string) []Key // Convert names into keys. +// ParseKeys combine multiple raw keys into grouped results. +func (n *DefaultNamer) ParseKeys(names []string, ignoreError bool) (Results, error) { + out := map[string][]Key{} + + for _, name := range names { + key, err := n.ParseKey(name) + switch { + case err != nil && ignoreError: + continue + case err != nil: + return Results{}, err + } + + out[key.name] = append(out[key.name], key) + } + + return NewResults(out), nil +} + +// parseSignatureKey parses a signature key from name parts. +func (n *DefaultNamer) parseSignatureKey(nameParts []string, originalName string) (DefaultKey, error) { + switch { + case len(nameParts) != maxKeyParts: + return DefaultKey{}, errInvalidKey(originalName, "found sig prefix, but key name is incomplete") + case len(nameParts[1]) == 0: + return DefaultKey{}, errInvalidKey(originalName, "found sig prefix, but hash name is invalid") + case len(nameParts[2]) == 0: + return DefaultKey{}, errInvalidKey(originalName, "found sig prefix, but key name is invalid") + case strings.HasSuffix(nameParts[2], "/"): + return DefaultKey{}, errInvalidKey(originalName, "found hash prefix, but key name is prefix") + } + + return NewDefaultKey( + nameParts[2], + KeyTypeSignature, + nameParts[1], + originalName, + ), nil } -// Generator generates signer K/V pairs. -// Implementation should use `generic` and will used for strong typing of the solution. -type Generator[T any] interface { - Generate(name string, value T) ([]kv.KeyValue, error) +// parseHashKey parses a hash key from name parts. +func (n *DefaultNamer) parseHashKey(nameParts []string, originalName string) (DefaultKey, error) { + switch { + case len(nameParts) != maxKeyParts: + return DefaultKey{}, errInvalidKey(originalName, "found hash prefix, but key name is incomplete") + case len(nameParts[1]) == 0: + return DefaultKey{}, errInvalidKey(originalName, "found hash prefix, but hash name is invalid") + case len(nameParts[2]) == 0: + return DefaultKey{}, errInvalidKey(originalName, "found hash prefix, but key name is invalid") + case strings.HasSuffix(nameParts[2], "/"): + return DefaultKey{}, errInvalidKey(originalName, "found hash prefix, but key name is prefix") + } + + return NewDefaultKey( + nameParts[2], + KeyTypeHash, + nameParts[1], + originalName, + ), nil } -// Validator validates and build the object from K/V. -type Validator[T any] interface { - Validate(pairs []kv.KeyValue) (T, error) +// parseValueKey parses a value key from name parts. +func (n *DefaultNamer) parseValueKey(name string, originalName string) (DefaultKey, error) { + if strings.HasSuffix(name, "/") { + return DefaultKey{}, errInvalidKey(originalName, "key name should not be prefix") + } + + return NewDefaultKey( + name, + KeyTypeValue, + "", + originalName, + ), nil } diff --git a/namer/namer_test.go b/namer/namer_test.go new file mode 100644 index 0000000..1465177 --- /dev/null +++ b/namer/namer_test.go @@ -0,0 +1,263 @@ +package namer_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/go-storage/namer" +) + +const ( + storagePrefix = "/storage" +) + +func TestDefaultNamer_GenerateNames_Invalid_Empty(t *testing.T) { + t.Parallel() + + dn := namer.NewDefaultNamer("/storage", nil, nil) + _, err := dn.GenerateNames("") + assert.Error(t, err) +} + +func TestDefaultNamer_GenerateNames_Invalid_Prefix(t *testing.T) { + t.Parallel() + + dn := namer.NewDefaultNamer("/storage", nil, nil) + _, err := dn.GenerateNames("123/") + assert.Error(t, err) +} + +func TestDefaultNamer_GenerateNames_Success(t *testing.T) { + t.Parallel() + + tests := []struct { + prefix string + name string + hashNames []string + sigNames []string + expected []namer.Key + }{ + { + "/storage", + "all", + []string{"sha256"}, + []string{"RSAPSS"}, + []namer.Key{ + namer.NewDefaultKey( + "all", + namer.KeyTypeValue, + "", + "/storage/all", + ), + namer.NewDefaultKey( + "all", + namer.KeyTypeHash, + "sha256", + "/storage/hash/sha256/all", + ), + namer.NewDefaultKey( + "all", + namer.KeyTypeSignature, + "RSAPSS", + "/storage/sig/RSAPSS/all", + ), + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dn := namer.NewDefaultNamer(tt.prefix, tt.hashNames, tt.sigNames) + + result, err := dn.GenerateNames(tt.name) + require.NoError(t, err) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestDefaultNamer_ParseKey_Success(t *testing.T) { + t.Parallel() + + prefix := storagePrefix + + tests := []struct { + input string + name string + tp namer.KeyType + property string + }{ + { + input: "/storage/all", + name: "all", + tp: namer.KeyTypeValue, + property: "", + }, + { + input: "/storage/hash/sha256/all", + name: "all", + tp: namer.KeyTypeHash, + property: "sha256", + }, + { + input: "/storage/sig/rsa/all", + name: "all", + tp: namer.KeyTypeSignature, + property: "rsa", + }, + } + + for _, tt := range tests { + t.Run("case:"+tt.input, func(t *testing.T) { + t.Parallel() + + dn := namer.NewDefaultNamer(prefix, nil, nil) + + key, err := dn.ParseKey(tt.input) + require.NoError(t, err) + assert.Equal(t, tt.name, key.Name()) + assert.Equal(t, tt.tp, key.Type()) + assert.Equal(t, tt.property, key.Property()) + assert.Equal(t, tt.input, key.Build()) + }) + } +} + +func TestDefaultNamer_ParseKey_Failed(t *testing.T) { + t.Parallel() + + prefix := storagePrefix + + tests := []string{ + "/not-storage/value", + "/storage/hash", + "/storage/hash/sha256", + "/storage/hash//all", + "/storage/hash/sha256/", + "/storage/hash/sha256/suffix/", + "/storage/sig", + "/storage/sig/rsa", + "/storage/sig//all", + "/storage/sig/rsa/", + "/storage/sig/rsa/suffix/", + "/storage/value-suffix/", + } + + for _, tt := range tests { + t.Run("case:"+tt, func(t *testing.T) { + t.Parallel() + + dn := namer.NewDefaultNamer(prefix, nil, nil) + _, err := dn.ParseKey(tt) + require.Error(t, err) + }) + } +} + +func TestDefaultNamer_ParseKeys_Success(t *testing.T) { + t.Parallel() + + prefix := storagePrefix + + tests := []struct { + name string + input []string + countMap map[string]int + singleName string + }{ + { + name: "non empty + single", + input: []string{ + "/storage/value", + "/storage/sig/signame/value", + "/storage/hash/hashname/value", + }, + countMap: map[string]int{"value": 3}, + singleName: "value", + }, + { + name: "non empty + multiple", + input: []string{ + "/storage/key1", + "/storage/sig/rsa/key1", + "/storage/hash/sha256/key1", + "/storage/key2", + "/storage/sig/rsa/key2", + "/storage/hash/sha256/key2", + }, + countMap: map[string]int{"key1": 3, "key2": 3}, + singleName: "", + }, + { + name: "empty", + input: []string{}, + countMap: map[string]int{}, + singleName: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + dn := namer.NewDefaultNamer(prefix, nil, nil) + + result, err := dn.ParseKeys(tt.input, false) + require.NoError(t, err) + require.Len(t, result.Result(), len(tt.countMap)) + + for k, v := range tt.countMap { + require.Contains(t, result.Result(), k) + assert.Len(t, result.Result()[k], v) + } + + if tt.singleName != "" { + _, ok := result.SelectSingle() + assert.True(t, ok) + } else { + _, ok := result.SelectSingle() + assert.False(t, ok) + } + }) + } +} + +func TestDefaultNamer_ParseKeys_Fail(t *testing.T) { + t.Parallel() + + prefix := storagePrefix + + t.Run("ignoreError = false", func(t *testing.T) { + t.Parallel() + + dn := namer.NewDefaultNamer(prefix, nil, nil) + _, err := dn.ParseKeys([]string{"/non-storage/value"}, false) + require.Error(t, err) + }) + + t.Run("ignoreError = true, skip all", func(t *testing.T) { + t.Parallel() + + dn := namer.NewDefaultNamer(prefix, nil, nil) + results, err := dn.ParseKeys([]string{"/non-storage/value"}, true) + require.NoError(t, err) + assert.Empty(t, results.Result()) + }) + + t.Run("ignoreError = true", func(t *testing.T) { + t.Parallel() + + dn := namer.NewDefaultNamer(prefix, nil, nil) + results, err := dn.ParseKeys([]string{ + "/non-storage/value", + "/storage/value-1", + }, true) + require.NoError(t, err) + assert.Len(t, results.Result(), 1) + assert.Contains(t, results.Result(), "value-1") + }) +} diff --git a/namer/results.go b/namer/results.go new file mode 100644 index 0000000..bcb9d95 --- /dev/null +++ b/namer/results.go @@ -0,0 +1,68 @@ +package namer + +import ( + "iter" +) + +// Results represents Namer working result. +type Results struct { + isSingle bool // True if result contains only one object name. + isSingleName string // Cached name when isSingle=true. + result map[string][]Key // Grouped keys: object name -> key list. +} + +func getFirstFromMap(m map[string][]Key) string { + for name := range m { + return name + } + + return "" +} + +// NewResults creates a new Results instance from the provided initial data. +func NewResults(initial map[string][]Key) Results { + return Results{ + isSingle: len(initial) == 1, + isSingleName: getFirstFromMap(initial), + result: initial, + } +} + +// SelectSingle gets keys for single-name case (if applicable). +func (r *Results) SelectSingle() ([]Key, bool) { + if r.isSingle { + return r.result[r.isSingleName], true + } + + return nil, false +} + +// Items return iterator over all name->keys groups. +func (r *Results) Items() iter.Seq2[string, []Key] { + return func(yield func(str string, res []Key) bool) { + for i, v := range r.result { + if !yield(i, v) { + return + } + } + } +} + +// Result returns the underlying results map. +func (r *Results) Result() map[string][]Key { + return r.result +} + +// Select gets keys for a specific object name. +func (r *Results) Select(name string) ([]Key, bool) { + if i, ok := r.result[name]; ok { + return i, true + } + + return nil, false +} + +// Len returns the number of unique object names. +func (r *Results) Len() int { + return len(r.result) +} diff --git a/namer/results_test.go b/namer/results_test.go new file mode 100644 index 0000000..9b3f3a3 --- /dev/null +++ b/namer/results_test.go @@ -0,0 +1,172 @@ +package namer_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/tarantool/go-storage/namer" +) + +func TestNewResults(t *testing.T) { + t.Parallel() + + assert.NotNil(t, namer.NewResults(map[string][]namer.Key{})) +} + +func TestResults_SelectSingle(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + results map[string][]namer.Key + isSingleResult bool + }{ + { + name: "single", + results: map[string][]namer.Key{"key": {}}, + isSingleResult: true, + }, + { + name: "multiple", + results: map[string][]namer.Key{"key1": {}, "key2": {}}, + isSingleResult: false, + }, + { + name: "empty", + results: map[string][]namer.Key{}, + isSingleResult: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := namer.NewResults(tt.results) + _, ok := r.SelectSingle() + require.Equal(t, ok, tt.isSingleResult) + }) + } +} + +func TestResults_Len(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + results map[string][]namer.Key + len int + }{ + { + name: "single", + results: map[string][]namer.Key{"key": {}}, + len: 1, + }, + { + name: "multiple", + results: map[string][]namer.Key{"key1": {}, "key2": {}}, + len: 2, + }, + { + name: "empty", + results: map[string][]namer.Key{}, + len: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + r := namer.NewResults(tt.results) + resultsLen := r.Len() + assert.Equal(t, resultsLen, tt.len) + }) + } +} + +func TestResults_Items(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + results map[string][]namer.Key + oneOf []string + }{ + { + name: "single", + results: map[string][]namer.Key{"key": {}}, + oneOf: []string{"key"}, + }, + { + name: "multiple", + results: map[string][]namer.Key{"key1": {}, "key2": {}}, + oneOf: []string{"key1", "key2"}, + }, + { + name: "empty", + results: map[string][]namer.Key{}, + oneOf: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + cnt := 0 + + results := namer.NewResults(tt.results) + for name := range results.Items() { + cnt++ + + assert.Contains(t, tt.oneOf, name) + } + + assert.Equal(t, len(tt.oneOf), cnt) + }) + } +} + +func TestResults_Select(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + results map[string][]namer.Key + oneOf []string + }{ + { + name: "single", + results: map[string][]namer.Key{"key": {}}, + oneOf: []string{"key"}, + }, + { + name: "multiple", + results: map[string][]namer.Key{"key1": {}, "key2": {}}, + oneOf: []string{"key1", "key2"}, + }, + { + name: "empty", + results: map[string][]namer.Key{}, + oneOf: []string{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + results := namer.NewResults(tt.results) + for _, name := range tt.oneOf { + _, ok := results.Select(name) + assert.True(t, ok) + } + + _, ok := results.Select("not-presented") + assert.False(t, ok) + }) + } +} From 99456290200cfe5a0542141d183430abd6b711b7 Mon Sep 17 00:00:00 2001 From: Eugene Blikh Date: Tue, 9 Dec 2025 04:09:53 +0300 Subject: [PATCH 2/2] integrity: implement basic structs needed for it Closes TNTP-4191 --- integrity/builder.go | 153 +++++++++++++++++++++++++++ integrity/generator.go | 131 +++++++++++++++++++++++ integrity/integrity_test.go | 106 +++++++++++++++++++ integrity/storage.go | 186 +++++++++++++++++++++++++++++++++ integrity/validator.go | 200 ++++++++++++++++++++++++++++++++++++ marshaller/marshaller.go | 35 +++++++ namer/namer.go | 7 ++ 7 files changed, 818 insertions(+) create mode 100644 integrity/builder.go create mode 100644 integrity/generator.go create mode 100644 integrity/integrity_test.go create mode 100644 integrity/storage.go create mode 100644 integrity/validator.go diff --git a/integrity/builder.go b/integrity/builder.go new file mode 100644 index 0000000..0b13b50 --- /dev/null +++ b/integrity/builder.go @@ -0,0 +1,153 @@ +// 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 ( + "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" +) + +// 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 + namer namer.Namer +} + +// 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: "/", + namer: nil, // TODO: implement default namer. + } +} + +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, + namer: s.namer, + } +} + +// WithHasher adds a hasher to the builder. +func (s TypedBuilder[T]) WithHasher(h hasher.Hasher) TypedBuilder[T] { + out := s.copy() + + s.hashers = append(s.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() + + s.signers = append(s.signers, sv) + s.verifiers = append(s.verifiers, sv) + + return out +} + +// WithSigner adds a signer to the builder. +func (s TypedBuilder[T]) WithSigner(signer crypto.Signer) TypedBuilder[T] { + out := s.copy() + + s.signers = append(s.signers, signer) + + return out +} + +// WithVerifier adds a verifier to the builder. +func (s TypedBuilder[T]) WithVerifier(verifier crypto.Verifier) TypedBuilder[T] { + out := s.copy() + + s.verifiers = append(s.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() + + s.marshaller = marshaller + + return out +} + +// WithPrefix sets the key prefix for the builder. +func (s TypedBuilder[T]) WithPrefix(prefix string) TypedBuilder[T] { + out := s.copy() + + s.prefix = prefix + + return out +} + +// WithNamer sets the namer for the builder. +func (s TypedBuilder[T]) WithNamer(namer namer.Namer) TypedBuilder[T] { + out := s.copy() + + s.namer = namer + + return out +} + +// Build creates a new Typed storage instance with the configured options. +func (s TypedBuilder[T]) Build() *Typed[T] { + var defaultNamer *namer.DefaultNamer + if s.namer == nil { + defaultNamer = namer.NewDefaultNamer(s.prefix, []string{}, []string{}) + } else { + var ok bool + + defaultNamer, ok = s.namer.(*namer.DefaultNamer) + if !ok { + panic("namer must be *namer.DefaultNamer") + } + } + + gen := NewGenerator( + defaultNamer, + s.marshaller, + s.hashers, + s.signers, + ) + + val := NewValidator( + defaultNamer, + s.marshaller, + s.hashers, + s.verifiers, + ) + + return &Typed[T]{ + base: s.storage, + gen: gen, + val: val, + namer: defaultNamer, + } +} diff --git a/integrity/generator.go b/integrity/generator.go new file mode 100644 index 0000000..0494f60 --- /dev/null +++ b/integrity/generator.go @@ -0,0 +1,131 @@ +package integrity + +import ( + "errors" + "fmt" + + "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" +) + +var ( + // ErrHasherNotFound is returned when a required hasher is not configured. + ErrHasherNotFound = errors.New("hasher not found") + // ErrSignerNotFound is returned when a required signer is not configured. + ErrSignerNotFound = errors.New("signer not found") + // ErrUnknownKeyType is returned for unexpected key types. + ErrUnknownKeyType = errors.New("unknown key type") + // ErrInvalidName is returned for invalid object names. + ErrInvalidName = errors.New("invalid name") + // ErrNoKeyValuePairs is returned when no key-value pairs are provided. + ErrNoKeyValuePairs = errors.New("no key-value pairs provided") + // ErrMultipleObjects is returned when multiple objects are found instead of one. + ErrMultipleObjects = errors.New("expected exactly one object") + // ErrMissingExpectedKey is returned when an expected key is missing. + ErrMissingExpectedKey = errors.New("missing expected key") + // ErrNoValueData is returned when no value data is found. + ErrNoValueData = errors.New("no value data found") + // ErrHashMismatch is returned when a hash doesn't match the expected value. + ErrHashMismatch = errors.New("hash mismatch") + // ErrVerifierNotFound is returned when a required verifier is not configured. + ErrVerifierNotFound = errors.New("verifier not found") + // ErrSignatureFailed is returned when signature verification fails. + ErrSignatureFailed = errors.New("signature verification failed") +) + +// Generator creates integrity-protected key-value pairs for storage. +type Generator[T any] struct { + namer *namer.DefaultNamer + marshaller marshaller.TypedYamlMarshaller[T] + hashers map[string]hasher.Hasher + signers map[string]crypto.Signer +} + +// NewGenerator creates a new Generator instance. +func NewGenerator[T any]( + namer *namer.DefaultNamer, + marshaller marshaller.TypedYamlMarshaller[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, fmt.Errorf("failed to generate keys: %w", err) + } + + marshalledValue, err := g.marshaller.Marshal(value) + if err != nil { + return nil, fmt.Errorf("failed to marshal value: %w", 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 { + return nil, fmt.Errorf("%w: %s", ErrHasherNotFound, key.Property()) + } + + var err error + + valueData, err = hasherInstance.Hash(marshalledValue) + if err != nil { + return nil, fmt.Errorf("failed to compute hash: %w", err) + } + + case namer.KeyTypeSignature: + signer, exists := g.signers[key.Property()] + if !exists { + return nil, fmt.Errorf("%w: %s", ErrSignerNotFound, key.Property()) + } + + var err error + + valueData, err = signer.Sign(marshalledValue) + if err != nil { + return nil, fmt.Errorf("failed to generate signature: %w", err) + } + + default: + return nil, fmt.Errorf("%w: %v", ErrUnknownKeyType, key.Type()) + } + + results = append(results, kv.KeyValue{ + Key: []byte(key.Build()), + Value: valueData, + ModRevision: 0, + }) + } + + return results, nil +} diff --git a/integrity/integrity_test.go b/integrity/integrity_test.go new file mode 100644 index 0000000..2bc1f8d --- /dev/null +++ b/integrity/integrity_test.go @@ -0,0 +1,106 @@ +package integrity_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "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 TestStruct struct { + Name string `yaml:"name"` + Value int `yaml:"value"` +} + +func TestGeneratorAndValidator(t *testing.T) { + t.Parallel() + + namer := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + marshaller := marshaller.NewTypedYamlMarshaller[TestStruct]() + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + + gen := integrity.NewGenerator[TestStruct]( + namer, + marshaller, + hashers, + nil, // no signers for this test. + ) + + val := integrity.NewValidator[TestStruct]( + namer, + marshaller, + hashers, + nil, // no verifiers for this test. + ) + + testData := TestStruct{ + Name: "test", + Value: 42, + } + + kvs, err := gen.Generate("myobject", testData) + require.NoError(t, err) + assert.NotEmpty(t, kvs) + + validated, err := val.Validate(kvs) + require.NoError(t, err) + assert.Equal(t, testData, validated) +} + +func TestValidatorWithMissingHash(t *testing.T) { + t.Parallel() + + namer := namer.NewDefaultNamer("test", []string{"sha256"}, []string{}) + marshaller := marshaller.NewTypedYamlMarshaller[TestStruct]() + hashers := []hasher.Hasher{hasher.NewSHA256Hasher()} + + gen := integrity.NewGenerator[TestStruct]( + namer, + marshaller, + hashers, + nil, + ) + + val := integrity.NewValidator[TestStruct]( + namer, + marshaller, + hashers, + nil, + ) + + testData := TestStruct{ + Name: "test", + Value: 42, + } + + kvs, err := gen.Generate("myobject", testData) + require.NoError(t, err) + + // Remove hash key to simulate missing hash. + var filteredKvs []kv.KeyValue + for _, kv := range kvs { + keyStr := string(kv.Key) + if !contains(keyStr, "hash") { + filteredKvs = append(filteredKvs, kv) + } + } + + _, err = val.Validate(filteredKvs) + assert.Error(t, err) + // Should fail because hash key is in parsed results but not in input. +} + +func contains(s, substr string) bool { + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return true + } + } + + return false +} diff --git a/integrity/storage.go b/integrity/storage.go new file mode 100644 index 0000000..a0cd8cf --- /dev/null +++ b/integrity/storage.go @@ -0,0 +1,186 @@ +package integrity + +import ( + "context" + "fmt" + "strings" + + "github.com/tarantool/go-storage" + "github.com/tarantool/go-storage/kv" + "github.com/tarantool/go-storage/namer" + "github.com/tarantool/go-storage/operation" + "github.com/tarantool/go-storage/watch" +) + +// NamedValue represents a named value with its associated name. +type NamedValue[T any] struct { + Name string + Value T +} + +// 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.DefaultNamer +} + +func checkName(name string) bool { + return len(name) == 0 || strings.Contains(name, "/") +} + +// Get retrieves and validates a named value from storage. +func (t *Typed[T]) Get(ctx context.Context, name string) (NamedValue[T], error) { + if !checkName(name) { + return NamedValue[T]{}, fmt.Errorf("%w", ErrInvalidName) + } + + keys, err := t.namer.GenerateNames(name) + if err != nil { + return NamedValue[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 NamedValue[T]{}, fmt.Errorf("%w: failed to execute", err) + } + + var kvs []kv.KeyValue + for _, r := range response.Results { + kvs = append(kvs, r.Values...) + } + + out, err := t.val.Validate(kvs) + if err != nil { + return NamedValue[T]{}, fmt.Errorf("%w: failed to validate", err) + } + + return NamedValue[T]{Name: name, Value: out}, nil +} + +// Put stores a named value with integrity protection. +func (t *Typed[T]) Put(name string, val T) error { + if !checkName(name) { + return fmt.Errorf("%w", 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)) + } + + response, err := t.base.Tx(context.Background()).Then(ops...).Commit() + if err != nil { + return fmt.Errorf("%w: failed to execute", err) + } + + _ = response + + return nil +} + +// Delete removes a named value and its integrity data from storage. +func (t *Typed[T]) Delete(name string) error { + if !checkName(name) { + return fmt.Errorf("%w", 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()))) + } + + response, err := t.base.Tx(context.Background()).Then(ops...).Commit() + if err != nil { + return fmt.Errorf("%w: failed to execute", err) + } + + _ = response + + return nil +} + +// Range retrieves and validates all values under the given name prefix. +func (t *Typed[T]) Range(ctx context.Context, name string) ([]NamedValue[T], error) { + if !checkName(name) { + return nil, fmt.Errorf("%w", ErrInvalidName) + } + + // Create a prefix operation to get all keys with the given name prefix. + prefix := name + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + // Use the storage.Range method to get all keys with this prefix. + kvs, err := t.base.Range(ctx, storage.WithPrefix(prefix)) + if err != nil { + return nil, fmt.Errorf("%w: failed to execute range", err) + } + + // Group key-value pairs by object name. + kvGroups := make(map[string][]kv.KeyValue) + for _, item := range kvs { + // Extract object name from the key by removing the prefix and type info. + keyStr := string(item.Key) + if strings.HasPrefix(keyStr, prefix) { + // Remove the prefix to get the object name. + objectName := name + if existing, exists := kvGroups[objectName]; exists { + kvGroups[objectName] = append(existing, item) + } else { + kvGroups[objectName] = []kv.KeyValue{item} + } + } + } + + // Validate each group and create NamedValue results. + results := make([]NamedValue[T], 0, len(kvGroups)) + for objectName, groupKvs := range kvGroups { + value, err := t.val.Validate(groupKvs) + if err != nil { + continue // Skip invalid entries. + } + + results = append(results, NamedValue[T]{Name: objectName, Value: value}) + } + + return results, 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 { + if !checkName(name) { + return closedChan() + } + + prefix := name + if !strings.HasSuffix(prefix, "/") { + prefix += "/" + } + + return t.base.Watch(ctx, []byte(prefix)) +} + +func closedChan() <-chan watch.Event { + ch := make(chan watch.Event) + close(ch) + + return ch +} diff --git a/integrity/validator.go b/integrity/validator.go new file mode 100644 index 0000000..1123039 --- /dev/null +++ b/integrity/validator.go @@ -0,0 +1,200 @@ +package integrity + +import ( + "fmt" + + "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.TypedYamlMarshaller[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.TypedYamlMarshaller[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, + } +} + +func zero[T any]() T { + var out T + return out +} + +// Validate verifies integrity-protected key-value pairs and returns the validated value. +func (v Validator[T]) Validate(kvs []kv.KeyValue) (T, error) { + if len(kvs) == 0 { + return zero[T](), fmt.Errorf("%w", ErrNoKeyValuePairs) + } + + kvMap, keyStrings := v.buildKVMaps(kvs) + + results, err := v.namer.ParseKeys(keyStrings, false) + if err != nil { + return zero[T](), fmt.Errorf("failed to parse keys: %w", err) + } + + if results.Len() != 1 { + return zero[T](), fmt.Errorf("%w: got %d", ErrMultipleObjects, results.Len()) + } + + objectName := v.getObjectName(results) + + expectedKeys, err := v.namer.GenerateNames(objectName) + if err != nil { + return zero[T](), fmt.Errorf("failed to generate expected keys: %w", err) + } + + valueData, err := v.validateExpectedKeys(expectedKeys, kvMap) + if err != nil { + return zero[T](), err + } + + err = v.validateHashesAndSignatures(expectedKeys, kvMap, valueData) + if err != nil { + return zero[T](), err + } + + value, err := v.marshaller.Unmarshal(valueData) + if err != nil { + return zero[T](), fmt.Errorf("failed to unmarshal value: %w", err) + } + + return value, nil +} + +func (v Validator[T]) buildKVMaps(kvs []kv.KeyValue) (map[string]kv.KeyValue, []string) { + keyStrings := make([]string, len(kvs)) + kvMap := make(map[string]kv.KeyValue) + + for i, kv := range kvs { + keyStr := string(kv.Key) + + keyStrings[i] = keyStr + kvMap[keyStr] = kv + } + + return kvMap, keyStrings +} + +func (v Validator[T]) getObjectName(results namer.Results) string { + for name := range results.Result() { + return name + } + + return "" +} + +func (v Validator[T]) validateExpectedKeys(expectedKeys []namer.Key, kvMap map[string]kv.KeyValue) ([]byte, error) { + var valueData []byte + + for _, key := range expectedKeys { + keyStr := key.Build() + kvItem, exists := kvMap[keyStr] + + if !exists { + return nil, fmt.Errorf("%w: %s", ErrMissingExpectedKey, keyStr) + } + + if key.Type() == namer.KeyTypeValue { + valueData = kvItem.Value + } + } + + if valueData == nil { + return nil, fmt.Errorf("%w", ErrNoValueData) + } + + return valueData, nil +} + +func (v Validator[T]) validateHashesAndSignatures( + expectedKeys []namer.Key, + kvMap map[string]kv.KeyValue, + valueData []byte, +) error { + for _, key := range expectedKeys { + keyStr := key.Build() + kvItem := kvMap[keyStr] + + switch key.Type() { + case namer.KeyTypeHash: + err := v.validateHash(key, kvItem, valueData) + if err != nil { + return err + } + + case namer.KeyTypeSignature: + err := v.validateSignature(key, kvItem, valueData) + if err != nil { + return err + } + + case namer.KeyTypeValue: + // Already handled. + default: + return fmt.Errorf("%w: %v", ErrUnknownKeyType, key.Type()) + } + } + + return nil +} + +func (v Validator[T]) validateHash(key namer.Key, kvItem kv.KeyValue, valueData []byte) error { + hasher, exists := v.hashers[key.Property()] + if !exists { + return fmt.Errorf("%w: %s", ErrHasherNotFound, key.Property()) + } + + expectedHash, err := hasher.Hash(valueData) + if err != nil { + return fmt.Errorf("failed to compute hash: %w", err) + } + + if string(expectedHash) != string(kvItem.Value) { + return fmt.Errorf("%w for hasher '%s'", ErrHashMismatch, key.Property()) + } + + return nil +} + +func (v Validator[T]) validateSignature(key namer.Key, kvItem kv.KeyValue, valueData []byte) error { + verifier, exists := v.verifiers[key.Property()] + if !exists { + return fmt.Errorf("%w: %s", ErrVerifierNotFound, key.Property()) + } + + err := verifier.Verify(valueData, kvItem.Value) + if err != nil { + return fmt.Errorf("%w: %w", ErrSignatureFailed, err) + } + + return nil +} diff --git a/marshaller/marshaller.go b/marshaller/marshaller.go index 9ec6f14..48f3e91 100644 --- a/marshaller/marshaller.go +++ b/marshaller/marshaller.go @@ -57,3 +57,38 @@ func (m YAMLMarshaller) Unmarshal(data []byte, out any) error { return nil } + +// 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{}, ErrMarshall + } + + return marshalled, nil +} + +func zero[T any]() T { + var out T + return out +} + +// 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](), ErrUnmarshall + } + + return out, nil +} 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/").