From 8f442a0d9a91ca1a532a6e2721d20872bb423560 Mon Sep 17 00:00:00 2001 From: seborama Date: Tue, 25 Jul 2023 22:13:42 +0100 Subject: [PATCH] introduce FileIO abstraction instead of hardcoded os.* function calls --- .github/workflows/ci.yml | 2 +- README.md | 4 +- cassette/cassette.go | 90 ++++++++++++++++++++++++++++++---------- cmd/govcr/main.go | 13 ++---- encryption/.study/rsa.go | 2 + fileio/os.go | 22 ++++++++++ govcr.go | 1 + 7 files changed, 100 insertions(+), 34 deletions(-) create mode 100644 fileio/os.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1a262fc..dee5380 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: test: name: Test - runs-on: ubuntu-18.04 + runs-on: ubuntu-20.04 steps: - uses: actions/checkout@v2 diff --git a/README.md b/README.md index 5493988..0b051e3 100644 --- a/README.md +++ b/README.md @@ -433,7 +433,7 @@ vcr := govcr.NewVCR( ### Recipe: Cassette decryption -**govcr** provides a CLI utility to decrypt existing cassette files, should we want to. +**govcr** provides a CLI utility to decrypt existing cassette files, should this be wanted. The command is located in the `cmd/govcr` folder, to install it: @@ -447,7 +447,7 @@ Example usage: govcr decrypt -cassette-file my.cassette.json -key-file my.key ``` -`decrypt` will cowardly refuse to write to a file to avoid errors or lingering decrypted files. It will write to the standard output. +`decrypt` writes to the standard output to avoid errors or lingering decrypted files. [(toc)](#table-of-content) diff --git a/cassette/cassette.go b/cassette/cassette.go index d285a2b..34cabab 100644 --- a/cassette/cassette.go +++ b/cassette/cassette.go @@ -18,6 +18,7 @@ import ( "github.com/seborama/govcr/v13/compression" cryptoerr "github.com/seborama/govcr/v13/encryption/errors" govcrerr "github.com/seborama/govcr/v13/errors" + "github.com/seborama/govcr/v13/fileio" "github.com/seborama/govcr/v13/stats" ) @@ -30,6 +31,14 @@ type Cassette struct { trackSliceMutex sync.RWMutex tracksLoaded int32 crypter Crypter + store FileIO +} + +type FileIO interface { + MkdirAll(path string, perm os.FileMode) error + ReadFile(name string) ([]byte, error) + WriteFile(name string, data []byte, perm os.FileMode) error + IsNotExist(err error) bool } const ( @@ -59,7 +68,19 @@ func WithCrypter(crypter Crypter) Option { } } +// WithStore provides a dedicated storage engine for the cassette data. +func WithStore(crypter Crypter) Option { + return func(k7 *Cassette) { + if k7.crypter != nil { + log.Println("notice: setting a crypter but another one had already been registered - this is incorrect usage") + } + + k7.crypter = crypter + } +} + // NewCassette creates a ready to use new cassette. +// When no storage backend (store) is provided, the default OSFile storage is used. func NewCassette(name string, opts ...Option) *Cassette { k7 := Cassette{ name: name, @@ -70,6 +91,10 @@ func NewCassette(name string, opts ...Option) *Cassette { option(&k7) } + if k7.store == nil { + k7.store = &fileio.OSFile{} + } + return &k7 } @@ -152,11 +177,15 @@ func (k7 *Cassette) wantEncrypted() bool { return k7.crypter != nil } -// saveCassette writes a cassette to file. +// saveCassette writes a cassette to storage. func (k7 *Cassette) save() error { k7.trackSliceMutex.Lock() defer k7.trackSliceMutex.Unlock() + if k7.store == nil { + k7.store = &fileio.OSFile{} + } + data, err := json.MarshalIndent(k7, "", " ") if err != nil { return errors.WithStack(err) @@ -174,11 +203,11 @@ func (k7 *Cassette) save() error { } path := filepath.Dir(k7.name) - if err = os.MkdirAll(path, 0o750); err != nil { + if err = k7.store.MkdirAll(path, 0o750); err != nil { return errors.Wrap(err, path) } - err = os.WriteFile(k7.name, eData, 0o600) + err = k7.store.WriteFile(k7.name, eData, 0o600) return errors.Wrap(err, k7.name) } @@ -276,37 +305,35 @@ func (k7 *Cassette) Name() string { return k7.name } -// readCassetteFile reads the cassette file, if present or -// returns a blank cassette. -func (k7 *Cassette) readCassetteFile(cassetteName string) error { +// readCassette reads the cassette source, if present or else nil data. +func (k7 *Cassette) readCassette(cassetteName string) ([]byte, error) { if cassetteName == "" { - return errors.New("a cassette name is required") + return nil, errors.New("a cassette name is required") + } + + if k7.store == nil { + k7.store = &fileio.OSFile{} } - data, err := os.ReadFile(cassetteName) // nolint:gosec + data, err := k7.store.ReadFile(cassetteName) if err != nil { - if os.IsNotExist(err) { - return nil + if k7.store.IsNotExist(err) { + return nil, nil // not found, return nil data } - return errors.Wrap(err, "failed to read cassette data from file") + return nil, errors.Wrap(err, "failed to read cassette data from source") } dData, err := k7.DecryptionFilter(data) if err != nil { - return errors.WithStack(err) + return nil, errors.WithStack(err) } gData, err := k7.GunzipFilter(dData) if err != nil { - return errors.WithStack(err) + return nil, errors.WithStack(err) } - // NOTE: Properties which are of type 'interface{} / any' are not handled very well - if err = json.Unmarshal(gData, k7); err != nil { - return errors.Wrap(err, "failed to interpret cassette data in file") - } - - return nil + return gData, nil } func getEncryptionMarker(data []byte) string { @@ -396,19 +423,40 @@ func AddTrackToCassette(cassette *Cassette, trk *track.Track) error { return cassette.save() } -// LoadCassette loads a cassette from file and initialises its associated stats. +// LoadCassette loads a cassette from source and initialises its associated stats. // It panics when a cassette exists but cannot be loaded because that indicates // corruption (or a severe bug). func LoadCassette(cassetteName string, opts ...Option) *Cassette { k7 := NewCassette(cassetteName, opts...) - err := k7.readCassetteFile(cassetteName) + data, err := k7.readCassette(cassetteName) if err != nil { panic(fmt.Sprintf("unable to invalid / load corrupted cassette '%s': %+v", cassetteName, err)) } + if data != nil { + // NOTE: Properties which are of type 'interface{} / any' are not handled very well + if err = json.Unmarshal(data, k7); err != nil { + panic(fmt.Sprintf("failed to interpret cassette data in source '%s': %+v", cassetteName, err)) + } + } + // initial stats atomic.StoreInt32(&k7.tracksLoaded, k7.NumberOfTracks()) return k7 } + +// DumpCassette loads a cassette from source and returns its (decrypted) contents. +// It panics when a cassette exists but cannot be loaded because that indicates +// corruption (or a severe bug). +func DumpCassette(cassetteName string, opts ...Option) []byte { + k7 := NewCassette(cassetteName, opts...) + + data, err := k7.readCassette(cassetteName) + if err != nil { + panic(fmt.Sprintf("unable to invalid / load corrupted cassette '%s': %+v", cassetteName, err)) + } + + return data +} diff --git a/cmd/govcr/main.go b/cmd/govcr/main.go index c6e46b6..cde7686 100644 --- a/cmd/govcr/main.go +++ b/cmd/govcr/main.go @@ -63,6 +63,7 @@ func decryptCommand(cassetteFile, keyFile string) error { return nil } +// TODO: offer ability to supply the key via an environment variable in base64 format. func decryptCassette(cassetteFile, keyFile string) (string, error) { key, err := os.ReadFile(keyFile) if err != nil { @@ -74,15 +75,7 @@ func decryptCassette(cassetteFile, keyFile string) (string, error) { return "", errors.Wrap(err, "cryptographer") } - k7RawData, err := os.ReadFile(cassetteFile) - if err != nil { - return "", errors.Wrap(err, "cassette file") - } - - k7Data, err := cassette.Decrypt(k7RawData, crypter) - if err != nil { - return "", errors.Wrap(err, "decryption") - } + data := cassette.DumpCassette(cassetteFile, cassette.WithCrypter(crypter)) - return string(k7Data), nil + return string(data), nil } diff --git a/encryption/.study/rsa.go b/encryption/.study/rsa.go index 1a4fc9b..c797127 100644 --- a/encryption/.study/rsa.go +++ b/encryption/.study/rsa.go @@ -15,6 +15,7 @@ import ( ) // nolint: deadcode +// TODO: offer ability to supply the key via an environment variable in base64 format. func readSSHRSAPrivateKeyFile(privKeyFile, passphrase string) (rsaPrivKey *rsa.PrivateKey, sshSigner ssh.Signer, rsaPubKey *rsa.PublicKey, err error) { keyData, err := os.ReadFile(privKeyFile) if err != nil { @@ -61,6 +62,7 @@ func readSSHRSAPrivateKeyFile(privKeyFile, passphrase string) (rsaPrivKey *rsa.P } // nolint: deadcode +// TODO: offer ability to supply the key via an environment variable in base64 format. func readSSHRSAPublicKeyFile(pubKeyFile string) (*rsa.PublicKey, error) { keyData, err := os.ReadFile(pubKeyFile) if err != nil { diff --git a/fileio/os.go b/fileio/os.go new file mode 100644 index 0000000..9bd7ae9 --- /dev/null +++ b/fileio/os.go @@ -0,0 +1,22 @@ +package fileio + +import "os" + +// OSFile provides a storage based on Go's standard "os" package for filesystem support. +type OSFile struct{} + +func (*OSFile) MkdirAll(path string, perm os.FileMode) error { + return os.MkdirAll(path, perm) +} + +func (*OSFile) ReadFile(name string) ([]byte, error) { + return os.ReadFile(name) +} + +func (*OSFile) WriteFile(name string, data []byte, perm os.FileMode) error { + return os.WriteFile(name, data, perm) +} + +func (*OSFile) IsNotExist(err error) bool { + return os.IsNotExist(err) +} diff --git a/govcr.go b/govcr.go index 99774dd..53610ac 100644 --- a/govcr.go +++ b/govcr.go @@ -67,6 +67,7 @@ func (cb *CassetteLoader) load() *cassette.Cassette { return cassette.LoadCassette(cb.cassetteName, cb.opts...) } +// TODO: offer ability to supply the key via an environment variable in base64 format. func makeCrypter(crypterNonce CrypterNonceProvider, keyFile string, nonceGenerator encryption.NonceGenerator) (*encryption.Crypter, error) { if crypterNonce == nil { return nil, errors.New("a cipher must be supplied for encryption, `nil` is not permitted")