From c15468f6fdcc77dc0d4ae87a97049fb38b8daa52 Mon Sep 17 00:00:00 2001 From: Paul Meyer <49727155+katexochen@users.noreply.github.com> Date: Wed, 29 May 2024 11:54:57 +0200 Subject: [PATCH] coordinator: implement history with fs store backend Signed-off-by: Paul Meyer <49727155+katexochen@users.noreply.github.com> --- coordinator/history/fsstore.go | 56 +++ coordinator/history/history.go | 231 +++++++++++ coordinator/history/history_test.go | 590 ++++++++++++++++++++++++++++ 3 files changed, 877 insertions(+) create mode 100644 coordinator/history/fsstore.go create mode 100644 coordinator/history/history.go create mode 100644 coordinator/history/history_test.go diff --git a/coordinator/history/fsstore.go b/coordinator/history/fsstore.go new file mode 100644 index 0000000000..2278788ce6 --- /dev/null +++ b/coordinator/history/fsstore.go @@ -0,0 +1,56 @@ +// Copyright 2024 Edgeless Systems GmbH +// SPDX-License-Identifier: AGPL-3.0-only + +package history + +import ( + "bytes" + "errors" + "fmt" + "io/fs" + "path/filepath" + "sync" + + "github.com/spf13/afero" +) + +type fsStore struct { + fs *afero.Afero + mux sync.RWMutex +} + +func newPVStore(fs *afero.Afero) *fsStore { + return &fsStore{fs: fs} +} + +func (s *fsStore) Get(key string) ([]byte, error) { + s.mux.RLock() + defer s.mux.RUnlock() + return s.fs.ReadFile(key) +} + +func (s *fsStore) Set(key string, value []byte) error { + s.mux.Lock() + defer s.mux.Unlock() + if err := s.fs.MkdirAll(filepath.Base(key), 0o755); err != nil { + return fmt.Errorf("creating directory for %q: %w", key, err) + } + return s.fs.WriteFile(key, value, 0o644) +} + +func (s *fsStore) CompareAndSwap(key string, oldVal, newVal []byte) error { + s.mux.Lock() + defer s.mux.Unlock() + current, err := s.fs.ReadFile(key) + // Treat non-existing file as empty to allow initial set. + if err != nil && !(errors.Is(err, fs.ErrNotExist) && len(oldVal) == 0) { + return err + } + if !bytes.Equal(current, oldVal) { + return fmt.Errorf("object %q has changed since last read", key) + } + if err := s.fs.MkdirAll(filepath.Base(key), 0o755); err != nil { + return fmt.Errorf("creating directory for %q: %w", key, err) + } + return s.fs.WriteFile(key, newVal, 0o644) +} diff --git a/coordinator/history/history.go b/coordinator/history/history.go new file mode 100644 index 0000000000..acaa3476a6 --- /dev/null +++ b/coordinator/history/history.go @@ -0,0 +1,231 @@ +// Copyright 2024 Edgeless Systems GmbH +// SPDX-License-Identifier: AGPL-3.0-only + +package history + +import ( + "bytes" + "crypto/ecdsa" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "hash" + + "github.com/spf13/afero" +) + +const ( + hashSize = 32 // byte, History.hashFun().Size() + histPath = "/mnt/state/history" +) + +// History is the history of the Coordinator. +type History struct { + store store + hashFun func() hash.Hash + signingKey *ecdsa.PrivateKey +} + +// New creates a new History with the given signing key. +func New() (*History, error) { + osFS := afero.NewOsFs() + if err := osFS.MkdirAll(histPath, 0o755); err != nil { + return nil, fmt.Errorf("creating history directory: %w", err) + } + h := &History{ + store: newPVStore(&afero.Afero{Fs: afero.NewBasePathFs(osFS, histPath)}), + hashFun: sha256.New, + } + if hashSize != h.hashFun().Size() { + return nil, errors.New("mismatch between hashSize and hash function size") + } + return h, nil +} + +// ConfigureSigningKey sets the signing key for validation and signing of the protected history parts. +func (h *History) ConfigureSigningKey(signingKey *ecdsa.PrivateKey) { + h.signingKey = signingKey +} + +// GetManifest returns the manifest for the given hash. +func (h *History) GetManifest(hash [hashSize]byte) ([]byte, error) { + return h.getContentaddressed("manifests/%s", hash) +} + +// SetManifest sets the manifest and returns its hash. +func (h *History) SetManifest(manifest []byte) ([hashSize]byte, error) { + return h.setContentaddressed("manifests/%s", manifest) +} + +// GetPolicy returns the policy for the given hash. +func (h *History) GetPolicy(hash [hashSize]byte) ([]byte, error) { + return h.getContentaddressed("policies/%s", hash) +} + +// SetPolicy sets the policy and returns its hash. +func (h *History) SetPolicy(policy []byte) ([hashSize]byte, error) { + return h.setContentaddressed("policies/%s", policy) +} + +// GetTransition returns the transition for the given hash. +func (h *History) GetTransition(hash [hashSize]byte) (*Transition, error) { + transitionBytes, err := h.getContentaddressed("transitions/%s", hash) + if err != nil { + return nil, err + } + var transition Transition + if err := transition.unmarshalBinary(transitionBytes); err != nil { + return nil, fmt.Errorf("unmarshaling transition: %w", err) + } + return &transition, nil +} + +// SetTransition sets the transition and returns its hash. +func (h *History) SetTransition(transition *Transition) ([hashSize]byte, error) { + return h.setContentaddressed("transitions/%s", transition.marshalBinary()) +} + +// GetLatest returns the verified transition for the given hash. +func (h *History) GetLatest() (*LatestTransition, error) { + if h.signingKey == nil { + return nil, errors.New("signing key not configured") + } + transitionBytes, err := h.store.Get("transitions/latest") + if err != nil { + return nil, fmt.Errorf("getting latest transition: %w", err) + } + var latestTransition LatestTransition + if err := latestTransition.unmarshalBinary(transitionBytes); err != nil { + return nil, fmt.Errorf("unmarshaling latest transition: %w", err) + } + if err := latestTransition.verify(&h.signingKey.PublicKey); err != nil { + return nil, fmt.Errorf("verifying latest transition: %w", err) + } + return &latestTransition, nil +} + +// SetLatest signs and sets the latest transition if the current latest is equal to oldT. +func (h *History) SetLatest(oldT, newT *LatestTransition) error { + if h.signingKey == nil { + return errors.New("signing key not configured") + } + if err := newT.sign(h.signingKey); err != nil { + return fmt.Errorf("signing latest transition: %w", err) + } + if err := h.store.CompareAndSwap("transitions/latest", oldT.marshalBinary(), newT.marshalBinary()); err != nil { + return fmt.Errorf("setting latest transition: %w", err) + } + return nil +} + +func (h *History) getContentaddressed(pathFmt string, hash [hashSize]byte) ([]byte, error) { + hashStr := hex.EncodeToString(hash[:]) + data, err := h.store.Get(fmt.Sprintf(pathFmt, hashStr)) + if err != nil { + return nil, err + } + dataHash := h.hash(data) + if !bytes.Equal(hash[:], dataHash[:]) { + return nil, HashMismatchError{Expected: hash[:], Actual: dataHash[:]} + } + return data, nil +} + +func (h *History) setContentaddressed(pathFmt string, data []byte) ([hashSize]byte, error) { + hash := h.hash(data) + hashStr := hex.EncodeToString(hash[:]) + if err := h.store.Set(fmt.Sprintf(pathFmt, hashStr), data); err != nil { + return [hashSize]byte{}, err + } + return hash, nil +} + +func (h *History) hash(in []byte) [hashSize]byte { + hf := h.hashFun() + _, _ = hf.Write(in) // Hash.Write never returns an error. + sum := hf.Sum(nil) + var hash [hashSize]byte + copy(hash[:], sum) // Correct len of sum enforced in constructor. + return hash +} + +// Transition is a transition between two manifests. +type Transition struct { + ManifestHash [hashSize]byte + PreviousTransitionHash [hashSize]byte +} + +func (t *Transition) unmarshalBinary(data []byte) error { + if len(data) != 2*hashSize { + return fmt.Errorf("transition has invalid length %d, expected %d", len(data), 2*hashSize) + } + copy(t.ManifestHash[:], data[:hashSize]) + copy(t.PreviousTransitionHash[:], data[hashSize:]) + return nil +} + +func (t *Transition) marshalBinary() []byte { + data := make([]byte, 2*hashSize) + copy(data[:hashSize], t.ManifestHash[:]) + copy(data[hashSize:], t.PreviousTransitionHash[:]) + return data +} + +// LatestTransition is the latest transition signed by the Coordinator. +type LatestTransition struct { + TransitionHash [hashSize]byte + signature []byte +} + +func (l *LatestTransition) unmarshalBinary(data []byte) error { + if len(data) <= hashSize { + return errors.New("latest transition has invalid length") + } + sigLen := len(data) - hashSize + l.signature = make([]byte, sigLen) + copy(l.TransitionHash[:], data[:hashSize]) + copy(l.signature, data[hashSize:]) + return nil +} + +func (l *LatestTransition) marshalBinary() []byte { + if l == nil { + return []byte{} + } + data := make([]byte, hashSize+len(l.signature)) + copy(data[:hashSize], l.TransitionHash[:]) + copy(data[hashSize:], l.signature) + return data +} + +func (l *LatestTransition) sign(key *ecdsa.PrivateKey) error { + var err error + l.signature, err = ecdsa.SignASN1(rand.Reader, key, l.TransitionHash[:]) + return err +} + +func (l *LatestTransition) verify(key *ecdsa.PublicKey) error { + if !ecdsa.VerifyASN1(key, l.TransitionHash[:], l.signature) { + return errors.New("latest transition signature is invalid") + } + return nil +} + +// HashMismatchError is returned when a hash does not match the expected value. +// This can occur when content addressed storage has been corrupted. +type HashMismatchError struct { + Expected []byte + Actual []byte +} + +func (e HashMismatchError) Error() string { + return fmt.Sprintf("hash mismatch: expected %x, got %x", e.Expected, e.Actual) +} + +type store interface { + Get(key string) ([]byte, error) + Set(key string, value []byte) error + CompareAndSwap(key string, oldVal, newVal []byte) error +} diff --git a/coordinator/history/history_test.go b/coordinator/history/history_test.go new file mode 100644 index 0000000000..d36f9931fe --- /dev/null +++ b/coordinator/history/history_test.go @@ -0,0 +1,590 @@ +// Copyright 2024 Edgeless Systems GmbH +// SPDX-License-Identifier: AGPL-3.0-only + +package history + +import ( + "crypto/ecdsa" + "crypto/sha256" + "crypto/x509" + "encoding/hex" + "encoding/pem" + "os" + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestHistory_GetLatest(t *testing.T) { + rq := require.New(t) + + testCases := map[string]struct { + fsContent map[string]string + signingKey *ecdsa.PrivateKey + wantT LatestTransition + wantErr bool + }{ + "success": { + fsContent: map[string]string{ + "transitions/latest": fromHex(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"+"304502210081e237315253991b496bdef5516527533a2bf828bae70a068be38ed612d5b90802207067b76f0a98e72282b276379e3b4d2857a37beea012c1bb3be9902cfc2d510c"), + }, + signingKey: testKey(rq), + wantT: LatestTransition{ + TransitionHash: strToHash(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + signature: []byte(fromHex(rq, "304502210081e237315253991b496bdef5516527533a2bf828bae70a068be38ed612d5b90802207067b76f0a98e72282b276379e3b4d2857a37beea012c1bb3be9902cfc2d510c")), + }, + }, + "hash modified": { + fsContent: map[string]string{ + "transitions/latest": fromHex(rq, "3cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"+"304502210081e237315253991b496bdef5516527533a2bf828bae70a068be38ed612d5b90802207067b76f0a98e72282b276379e3b4d2857a37beea012c1bb3be9902cfc2d510c"), + }, + signingKey: testKey(rq), + wantErr: true, + }, + "signature modified": { + fsContent: map[string]string{ + "transitions/latest": fromHex(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"+"404502210081e237315253991b496bdef5516527533a2bf828bae70a068be38ed612d5b90802207067b76f0a98e72282b276379e3b4d2857a37beea012c1bb3be9902cfc2d510c"), + }, + signingKey: testKey(rq), + wantErr: true, + }, + "no latest": { + fsContent: map[string]string{}, + signingKey: testKey(rq), + wantErr: true, + }, + "no signature": { + fsContent: map[string]string{ + "transitions/latest": fromHex(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + }, + signingKey: testKey(rq), + wantErr: true, + }, + "signing key missing": { + fsContent: map[string]string{ + "transitions/latest": fromHex(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"+"304502210081e237315253991b496bdef5516527533a2bf828bae70a068be38ed612d5b90802207067b76f0a98e72282b276379e3b4d2857a37beea012c1bb3be9902cfc2d510c"), + }, + wantT: LatestTransition{ + TransitionHash: strToHash(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + signature: []byte(fromHex(rq, "304502210081e237315253991b496bdef5516527533a2bf828bae70a068be38ed612d5b90802207067b76f0a98e72282b276379e3b4d2857a37beea012c1bb3be9902cfc2d510c")), + }, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + fs := afero.Afero{Fs: afero.NewMemMapFs()} + for path, content := range tc.fsContent { + require.NoError(fs.WriteFile(path, []byte(content), 0o644)) + } + + h := &History{ + store: newPVStore(&fs), + hashFun: sha256.New, + signingKey: tc.signingKey, + } + + gotT, err := h.GetLatest() + + if tc.wantErr { + require.Error(err) + return + } + require.NoError(err) + require.NotNil(gotT) + assert.Equal(tc.wantT, *gotT) + }) + } +} + +func TestHistory_SetLatest(t *testing.T) { + rq := require.New(t) + testCases := map[string]struct { + fsContent map[string]string + fsRo bool + signingKey *ecdsa.PrivateKey + oldT *LatestTransition + newT *LatestTransition + wantErr bool + }{ + "success": { + fsContent: map[string]string{ + "transitions/latest": fromHex(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824") + "+sig", + }, + signingKey: testKey(rq), + oldT: &LatestTransition{ + TransitionHash: strToHash(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + signature: []byte("+sig"), + }, + newT: &LatestTransition{ + TransitionHash: strToHash(rq, "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + }, + "initial transition": { + fsContent: map[string]string{}, + signingKey: testKey(rq), + oldT: nil, + newT: &LatestTransition{ + TransitionHash: strToHash(rq, "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + }, + "write error": { + fsContent: map[string]string{ + "transitions/latest": fromHex(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824") + "+sig", + }, + signingKey: testKey(rq), + oldT: &LatestTransition{ + TransitionHash: strToHash(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + signature: []byte("+sig"), + }, + newT: &LatestTransition{ + TransitionHash: strToHash(rq, "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + fsRo: true, + wantErr: true, + }, + "latest not existing": { + fsContent: map[string]string{}, + signingKey: testKey(rq), + oldT: &LatestTransition{ + TransitionHash: strToHash(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + signature: []byte("+sig"), + }, + newT: &LatestTransition{ + TransitionHash: strToHash(rq, "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + fsRo: true, + wantErr: true, + }, + "latest updated": { + fsContent: map[string]string{ + "transitions/latest": fromHex(rq, "c3ab8ff13720e8ad9047dd39466b3c8974e592c2fa383d4a3960714caef0c4f2") + "+sig", + }, + signingKey: testKey(rq), + oldT: &LatestTransition{ + TransitionHash: strToHash(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + signature: []byte("+sig"), + }, + newT: &LatestTransition{ + TransitionHash: strToHash(rq, "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + fsRo: true, + wantErr: true, + }, + "signing key missing": { + fsContent: map[string]string{ + "transitions/latest": fromHex(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824") + "+sig", + }, + oldT: &LatestTransition{ + TransitionHash: strToHash(rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + signature: []byte("+sig"), + }, + newT: &LatestTransition{ + TransitionHash: strToHash(rq, "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + require := require.New(t) + + fs := afero.Afero{Fs: afero.NewMemMapFs()} + for path, content := range tc.fsContent { + require.NoError(fs.WriteFile(path, []byte(content), 0o644)) + } + if tc.fsRo { + fs = afero.Afero{Fs: afero.NewReadOnlyFs(fs.Fs)} + } + + h := &History{ + store: newPVStore(&fs), + hashFun: sha256.New, + signingKey: tc.signingKey, + } + + err := h.SetLatest(tc.oldT, tc.newT) + + if tc.wantErr { + require.Error(err) + return + } + require.NoError(err) + }) + } +} + +func TestHistory_GetTransition(t *testing.T) { + rq := require.New(t) + testCases := map[string]struct { + fsContent map[string]string + hash string + wantTransition Transition + wantErr bool + }{ + "success": { + fsContent: map[string]string{ + "transitions/7305db9b2abccd706c256db3d97e5ff48d677cfe4d3a5904afb7da0e3950e1e2": fromHex( + rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + hash: "7305db9b2abccd706c256db3d97e5ff48d677cfe4d3a5904afb7da0e3950e1e2", + wantTransition: Transition{ + ManifestHash: strToHash( + rq, "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + PreviousTransitionHash: strToHash( + rq, "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + }, + "not found": { + fsContent: map[string]string{}, + hash: "7305db9b2abccd706c256db3d97e5ff48d677cfe4d3a5904afb7da0e3950e1e2", + wantErr: true, + }, + "unmarshal error": { + fsContent: map[string]string{ + "transitions/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824": "hello", + }, + hash: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + fs := afero.Afero{Fs: afero.NewMemMapFs()} + for path, content := range tc.fsContent { + require.NoError(fs.WriteFile(path, []byte(content), 0o644)) + } + + h := &History{ + store: newPVStore(&fs), + hashFun: sha256.New, + } + + gotTransition, err := h.GetTransition(strToHash(require, tc.hash)) + + if tc.wantErr { + require.Error(err) + t.Log(err) + return + } + require.NoError(err) + require.NotNil(gotTransition) + assert.Equal(tc.wantTransition, *gotTransition) + }) + } +} + +func TestHistory_SetTransition(t *testing.T) { + testCases := map[string]struct { + fsContent map[string]string + fsRo bool + transition Transition + wantHash string + wantFSContent map[string]string + wantErr bool + }{ + "success": { + fsContent: map[string]string{}, + transition: Transition{ + ManifestHash: strToHash(require.New(t), "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + PreviousTransitionHash: strToHash(require.New(t), "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + wantHash: "7305db9b2abccd706c256db3d97e5ff48d677cfe4d3a5904afb7da0e3950e1e2", + wantFSContent: map[string]string{ + "transitions/7305db9b2abccd706c256db3d97e5ff48d677cfe4d3a5904afb7da0e3950e1e2": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7", + }, + }, + "object exists": { + fsContent: map[string]string{ + "transitions/7305db9b2abccd706c256db3d97e5ff48d677cfe4d3a5904afb7da0e3950e1e2": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7", + }, + transition: Transition{ + ManifestHash: strToHash(require.New(t), "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + PreviousTransitionHash: strToHash(require.New(t), "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + wantHash: "7305db9b2abccd706c256db3d97e5ff48d677cfe4d3a5904afb7da0e3950e1e2", + wantFSContent: map[string]string{ + "transitions/7305db9b2abccd706c256db3d97e5ff48d677cfe4d3a5904afb7da0e3950e1e2": "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7", + }, + }, + "write error": { + fsContent: map[string]string{}, + fsRo: true, + transition: Transition{ + ManifestHash: strToHash(require.New(t), "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"), + PreviousTransitionHash: strToHash(require.New(t), "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7"), + }, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + fs := afero.Afero{Fs: afero.NewMemMapFs()} + for path, content := range tc.fsContent { + require.NoError(fs.WriteFile(path, []byte(content), 0o644)) + } + if tc.fsRo { + fs = afero.Afero{Fs: afero.NewReadOnlyFs(fs.Fs)} + } + + h := &History{ + store: newPVStore(&fs), + hashFun: sha256.New, + } + + gotHash, err := h.SetTransition(&tc.transition) + + if tc.wantErr { + require.Error(err) + return + } + require.NoError(err) + + gotFSContent := map[string]string{} + require.NoError(fs.Walk("", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if info.Mode().IsRegular() { + content, err := fs.ReadFile(path) + require.NoError(err) + gotFSContent[path] = hex.EncodeToString(content) + } + return nil + })) + assert.Equal(tc.wantHash, hex.EncodeToString(gotHash[:])) + assert.Equal(tc.wantFSContent, gotFSContent) + }) + } +} + +func TestHistory_getCA(t *testing.T) { + testCases := map[string]struct { + fsContent map[string]string + hash string + wantBytes string + wantErr bool + }{ + "success": { + fsContent: map[string]string{ + "tests/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824": "hello", + "tests/486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7": "world", + }, + hash: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + wantBytes: "hello", + }, + "not found": { + fsContent: map[string]string{ + "tests/486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7": "world", + }, + hash: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + wantErr: true, + }, + "hash mismatch": { + fsContent: map[string]string{ + "tests/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824": "hello!", + "tests/486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7": "world", + }, + hash: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + require := require.New(t) + + fs := afero.Afero{Fs: afero.NewMemMapFs()} + for path, content := range tc.fsContent { + require.NoError(fs.WriteFile(path, []byte(content), 0o644)) + } + + h := &History{ + store: newPVStore(&fs), + hashFun: sha256.New, + } + + hash := strToHash(require, tc.hash) + + gotBytes, err := h.getContentaddressed("tests/%s", hash) + + if tc.wantErr { + require.Error(err) + return + } + require.NoError(err) + require.Equal(tc.wantBytes, string(gotBytes)) + }) + } +} + +func TestHistory_setCA(t *testing.T) { + testCases := map[string]struct { + fsContent map[string]string + fsRo bool + pathFmt string + data string + wantHash string + wantFSContent map[string]string + wantErr bool + }{ + "success hello": { + fsContent: map[string]string{}, + pathFmt: "tests/%s", + data: "hello", + wantHash: "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824", + wantFSContent: map[string]string{ + "tests/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824": "hello", + }, + }, + "success world": { + fsContent: map[string]string{ + "tests/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824": "hello", + }, + pathFmt: "tests/%s", + data: "world", + wantHash: "486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7", + wantFSContent: map[string]string{ + "tests/2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824": "hello", + "tests/486ea46224d1bb4fb680f34f7c9ad96a8f24ec88be73ea8e5a6c65260e9cb8a7": "world", + }, + }, + "write error": { + fsContent: map[string]string{}, + pathFmt: "tests/%s", + data: "hello", + fsRo: true, + wantErr: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + + fs := afero.Afero{Fs: afero.NewMemMapFs()} + for path, content := range tc.fsContent { + require.NoError(fs.WriteFile(path, []byte(content), 0o644)) + } + if tc.fsRo { + fs = afero.Afero{Fs: afero.NewReadOnlyFs(fs.Fs)} + } + + h := &History{ + store: newPVStore(&fs), + hashFun: sha256.New, + } + + gotHash, err := h.setContentaddressed(tc.pathFmt, []byte(tc.data)) + + if tc.wantErr { + require.Error(err) + return + } + require.NoError(err) + + gotFSContent := map[string]string{} + require.NoError(fs.Walk("", func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() { + return nil + } + if info.Mode().IsRegular() { + content, err := fs.ReadFile(path) + require.NoError(err) + gotFSContent[path] = string(content) + } + return nil + })) + assert.Equal(tc.wantFSContent, gotFSContent) + assert.Equal(tc.wantHash, hex.EncodeToString(gotHash[:])) + }) + } +} + +func TestHistory_SetGet(t *testing.T) { + h := &History{ + store: &fsStore{fs: &afero.Afero{Fs: afero.NewMemMapFs()}}, + hashFun: sha256.New, + } + + testCases := []string{ + "hello", + "world", + "Nun ich verkündige dir, merk auf, und höre die Worte!" + + "Denke nach: wird uns Athene und Vater Kronion" + + "Gnügen; oder ist's nötig, noch andere Hilfe zu suchen?", + } + testFunPairs := map[string]struct { + setFun func([]byte) ([hashSize]byte, error) + getFun func([hashSize]byte) ([]byte, error) + }{ + "manifest": {h.SetManifest, h.GetManifest}, + "policy": {h.SetPolicy, h.GetPolicy}, + } + + for name, tc := range testFunPairs { + for _, data := range testCases { + t.Run(name+"_"+data[:5], func(t *testing.T) { + require := require.New(t) + + hash, err := tc.setFun([]byte(data)) + require.NoError(err) + + gotData, err := tc.getFun(hash) + require.NoError(err) + require.Equal(data, string(gotData)) + }) + } + } +} + +func testKey(require *require.Assertions) *ecdsa.PrivateKey { + const testKey = `-----BEGIN EC PRIVATE KEY----- +MHcCAQEEIAVovia1Gq3uYyMn2MUHN7iZzB063CsASbjmeR1M4yXxoAoGCCqGSM49 +AwEHoUQDQgAEodJSQKBrTfw5S/QMPRJtNbBSuifKdEbcEV7d4a1C/HypH8Wyu/Z3 +xuwYqSFfVxr6ECQWyrTkApzVkz8b6n5BeQ== +-----END EC PRIVATE KEY-----` + // parse the test key from pem + p, rest := pem.Decode([]byte(testKey)) + require.Empty(rest) + key, err := x509.ParseECPrivateKey(p.Bytes) + require.NoError(err) + return key +} + +func strToHash(require *require.Assertions, s string) [hashSize]byte { + hashSlc, err := hex.DecodeString(s) + require.NoError(err) + require.Len(hashSlc, hashSize) + var hash [hashSize]byte + copy(hash[:], hashSlc) + return hash +} + +func fromHex(require *require.Assertions, s string) string { + data, err := hex.DecodeString(s) + require.NoError(err) + return string(data) +}