From 4fafe3d5d0650c0622d3e5cb259ec98f017af790 Mon Sep 17 00:00:00 2001 From: Jakub Warczarek Date: Tue, 7 Jan 2025 11:51:58 +0100 Subject: [PATCH] feat: introduce ReadOnlyFileStore Signed-off-by: Jakub Warczarek --- .../credentials/internal/config/config.go | 19 +- .../internal/config/readonly_config.go | 50 ++++ .../internal/config/readonly_config_test.go | 257 ++++++++++++++++++ .../credentials/internal/config/utils.go | 30 ++ .../remote/credentials/readonly_file_store.go | 47 ++++ .../credentials/readonly_file_store_test.go | 170 ++++++++++++ registry/remote/credentials/registry.go | 2 +- registry/remote/credentials/store.go | 9 +- 8 files changed, 565 insertions(+), 19 deletions(-) create mode 100644 registry/remote/credentials/internal/config/readonly_config.go create mode 100644 registry/remote/credentials/internal/config/readonly_config_test.go create mode 100644 registry/remote/credentials/internal/config/utils.go create mode 100644 registry/remote/credentials/readonly_file_store.go create mode 100644 registry/remote/credentials/readonly_file_store_test.go diff --git a/registry/remote/credentials/internal/config/config.go b/registry/remote/credentials/internal/config/config.go index 20ee0743..6b4bee21 100644 --- a/registry/remote/credentials/internal/config/config.go +++ b/registry/remote/credentials/internal/config/config.go @@ -160,25 +160,12 @@ func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) cfg.rwLock.RLock() defer cfg.rwLock.RUnlock() - authCfgBytes, ok := cfg.authsCache[serverAddress] + authCfgRaw, ok := matchAuth(cfg.authsCache, serverAddress) if !ok { - // NOTE: the auth key for the server address may have been stored with - // a http/https prefix in legacy config files, e.g. "registry.example.com" - // can be stored as "https://registry.example.com/". - var matched bool - for addr, auth := range cfg.authsCache { - if toHostname(addr) == serverAddress { - matched = true - authCfgBytes = auth - break - } - } - if !matched { - return auth.EmptyCredential, nil - } + return auth.EmptyCredential, nil } var authCfg AuthConfig - if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil { + if err := json.Unmarshal(authCfgRaw, &authCfg); err != nil { return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err) } return authCfg.Credential() diff --git a/registry/remote/credentials/internal/config/readonly_config.go b/registry/remote/credentials/internal/config/readonly_config.go new file mode 100644 index 00000000..cdd8e497 --- /dev/null +++ b/registry/remote/credentials/internal/config/readonly_config.go @@ -0,0 +1,50 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "encoding/json" + "fmt" + "io" + + "oras.land/oras-go/v2/registry/remote/auth" +) + +// ReadOnlyConfig represents authentication credentials parsed from a standard config file, +// which are read to use. It is read-only - only GetCredential is supported. +type ReadOnlyConfig struct { + Auths map[string]AuthConfig `json:"auths"` +} + +// LoadFromReader creates a new ReadOnlyConfig from the given reader that contains a standard +// config file content. It returns an error if the content is not in the expected format. +func LoadFromReader(reader io.Reader) (*ReadOnlyConfig, error) { + var cfg ReadOnlyConfig + if err := json.NewDecoder(reader).Decode(&cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err) + } + return &cfg, nil +} + +// GetCredential returns the credential for the given server address. For non-existent server address, +// it returns auth.EmptyCredential. +func (cfg *ReadOnlyConfig) GetCredential(serverAddress string) (auth.Credential, error) { + authCfg, ok := matchAuth(cfg.Auths, serverAddress) + if !ok { + return auth.EmptyCredential, nil + } + return authCfg.Credential() +} diff --git a/registry/remote/credentials/internal/config/readonly_config_test.go b/registry/remote/credentials/internal/config/readonly_config_test.go new file mode 100644 index 00000000..2adc5ebe --- /dev/null +++ b/registry/remote/credentials/internal/config/readonly_config_test.go @@ -0,0 +1,257 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +import ( + "bytes" + "errors" + "os" + "reflect" + "strings" + "testing" + + "oras.land/oras-go/v2/registry/remote/auth" +) + +func TestReadOnlyConfig_Create_fromInvalidConfig(t *testing.T) { + f, err := os.ReadFile("../../testdata/invalid_auths_entry_config.json") + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + _, err = LoadFromReader(bytes.NewReader(f)) + if !errors.Is(err, ErrInvalidConfigFormat) { + t.Fatalf("Error: %s is expected", ErrInvalidConfigFormat) + } +} + +func TestReadOnlyConfig_GetCredential_validConfig(t *testing.T) { + f, err := os.ReadFile("../../testdata/valid_auths_config.json") + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + cfg, err := LoadFromReader(bytes.NewReader(f)) + if err != nil { + t.Fatal("LoadFromReader() error =", err) + } + + tests := []struct { + name string + serverAddress string + want auth.Credential + wantErr bool + }{ + { + name: "Username and password", + serverAddress: "registry1.example.com", + want: auth.Credential{ + Username: "username", + Password: "password", + }, + }, + { + name: "Identity token", + serverAddress: "registry2.example.com", + want: auth.Credential{ + RefreshToken: "identity_token", + }, + }, + { + name: "Registry token", + serverAddress: "registry3.example.com", + want: auth.Credential{ + AccessToken: "registry_token", + }, + }, + { + name: "Username and password, identity token and registry token", + serverAddress: "registry4.example.com", + want: auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "identity_token", + AccessToken: "registry_token", + }, + }, + { + name: "Empty credential", + serverAddress: "registry5.example.com", + want: auth.EmptyCredential, + }, + { + name: "Username and password, no auth", + serverAddress: "registry6.example.com", + want: auth.Credential{ + Username: "username", + Password: "password", + }, + }, + { + name: "Auth overriding Username and password", + serverAddress: "registry7.example.com", + want: auth.Credential{ + Username: "username", + Password: "password", + }, + }, + { + name: "Not in auths", + serverAddress: "foo.example.com", + want: auth.EmptyCredential, + }, + { + name: "No record", + serverAddress: "registry999.example.com", + want: auth.EmptyCredential, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := cfg.GetCredential(tt.serverAddress) + if (err != nil) != tt.wantErr { + t.Errorf("ReadOnlyConfig.GetCredential() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ReadOnlyConfig.GetCredential() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReadOnlyConfig_GetCredential_legacyConfig(t *testing.T) { + f, err := os.ReadFile("../../testdata/legacy_auths_config.json") + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + cfg, err := LoadFromReader(bytes.NewReader(f)) + if err != nil { + t.Fatal("LoadFromReader() error =", err) + } + + tests := []struct { + name string + serverAddress string + want auth.Credential + wantErr bool + }{ + { + name: "Regular address matched", + serverAddress: "registry1.example.com", + want: auth.Credential{ + Username: "username1", + Password: "password1", + }, + }, + { + name: "Another entry for the same address matched", + serverAddress: "https://registry1.example.com/", + want: auth.Credential{ + Username: "foo", + Password: "bar", + }, + }, + { + name: "Address with different scheme unmached", + serverAddress: "http://registry1.example.com/", + want: auth.EmptyCredential, + }, + { + name: "Address with http prefix matched", + serverAddress: "registry2.example.com", + want: auth.Credential{ + Username: "username2", + Password: "password2", + }, + }, + { + name: "Address with https prefix matched", + serverAddress: "registry3.example.com", + want: auth.Credential{ + Username: "username3", + Password: "password3", + }, + }, + { + name: "Address with http prefix and / suffix matched", + serverAddress: "registry4.example.com", + want: auth.Credential{ + Username: "username4", + Password: "password4", + }, + }, + { + name: "Address with https prefix and / suffix matched", + serverAddress: "registry5.example.com", + want: auth.Credential{ + Username: "username5", + Password: "password5", + }, + }, + { + name: "Address with https prefix and path suffix matched", + serverAddress: "registry6.example.com", + want: auth.Credential{ + Username: "username6", + Password: "password6", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := cfg.GetCredential(tt.serverAddress) + if (err != nil) != tt.wantErr { + t.Errorf("ReadOnlyConfig.GetCredential() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ReadOnlyConfig.GetCredential() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReadOnlyConfig_GetCredential_emptyConfig(t *testing.T) { + const validEmptyJson = "{}" + cfg, err := LoadFromReader(strings.NewReader(validEmptyJson)) + if err != nil { + t.Fatal("LoadFromReader() error =", err) + } + tests := []struct { + name string + serverAddress string + want auth.Credential + wantErr error + }{ + { + name: "Not found", + serverAddress: "registry.example.com", + want: auth.EmptyCredential, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := cfg.GetCredential(tt.serverAddress) + if !errors.Is(err, tt.wantErr) { + t.Errorf("ReadOnlyConfig.GetCredential() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ReadOnlyConfig.GetCredential() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/registry/remote/credentials/internal/config/utils.go b/registry/remote/credentials/internal/config/utils.go new file mode 100644 index 00000000..747a6c32 --- /dev/null +++ b/registry/remote/credentials/internal/config/utils.go @@ -0,0 +1,30 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package config + +// matchAuth is a helper function that matches the server address with the auths map. +func matchAuth[V any](auths map[string]V, serverAddress string) (V, bool) { + if v, ok := auths[serverAddress]; ok { + return v, true + } + for addr, v := range auths { + if toHostname(addr) == serverAddress { + return v, true + } + } + var zero V + return zero, false +} diff --git a/registry/remote/credentials/readonly_file_store.go b/registry/remote/credentials/readonly_file_store.go new file mode 100644 index 00000000..58925aa2 --- /dev/null +++ b/registry/remote/credentials/readonly_file_store.go @@ -0,0 +1,47 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentials + +import ( + "context" + "io" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config" +) + +// ReadOnlyFileStore implements a credentials store using the docker configuration file +// as an input. It supports only Get operation that works in the same way as for standard +// FileStore. +type ReadOnlyFileStore struct { + cfg *config.ReadOnlyConfig +} + +// NewReadOnlyFileStore creates a new file credentials store based on the given config, +// it returns an error if the config is not in the expected format. +func NewReadOnlyFileStore(reader io.Reader) (*ReadOnlyFileStore, error) { + cfg, err := config.LoadFromReader(reader) + if err != nil { + return nil, err + } + return &ReadOnlyFileStore{cfg: cfg}, nil +} + +// Get retrieves credentials from the store for the given server address. In case of non-existent +// server address, it returns auth.EmptyCredential. +func (fs *ReadOnlyFileStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) { + return fs.cfg.GetCredential(serverAddress) +} diff --git a/registry/remote/credentials/readonly_file_store_test.go b/registry/remote/credentials/readonly_file_store_test.go new file mode 100644 index 00000000..e8f03dd3 --- /dev/null +++ b/registry/remote/credentials/readonly_file_store_test.go @@ -0,0 +1,170 @@ +/* +Copyright The ORAS Authors. +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + +http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package credentials + +import ( + "bytes" + "context" + "errors" + "os" + "reflect" + "strings" + "testing" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config" +) + +func TestReadOnlyFileStore_Create_fromInvalidConfig(t *testing.T) { + f, err := os.ReadFile("testdata/invalid_auths_entry_config.json") + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + _, err = NewReadOnlyFileStore(bytes.NewReader(f)) + if !errors.Is(err, config.ErrInvalidConfigFormat) { + t.Fatalf("Error: %s is expected", config.ErrInvalidConfigFormat) + } +} + +func TestReadOnlyFileStore_Get_validConfig(t *testing.T) { + ctx := context.Background() + f, err := os.ReadFile("testdata/valid_auths_config.json") + if err != nil { + t.Fatalf("failed to read file: %v", err) + } + rofs, err := NewReadOnlyFileStore(bytes.NewReader(f)) + if err != nil { + t.Fatalf("NewReadOnlyFileStore() error = %v", err) + } + + tests := []struct { + name string + serverAddress string + want auth.Credential + wantErr bool + }{ + { + name: "Username and password", + serverAddress: "registry1.example.com", + want: auth.Credential{ + Username: "username", + Password: "password", + }, + }, + { + name: "Identity token", + serverAddress: "registry2.example.com", + want: auth.Credential{ + RefreshToken: "identity_token", + }, + }, + { + name: "Registry token", + serverAddress: "registry3.example.com", + want: auth.Credential{ + AccessToken: "registry_token", + }, + }, + { + name: "Username and password, identity token and registry token", + serverAddress: "registry4.example.com", + want: auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "identity_token", + AccessToken: "registry_token", + }, + }, + { + name: "Empty credential", + serverAddress: "registry5.example.com", + want: auth.EmptyCredential, + }, + { + name: "Username and password, no auth", + serverAddress: "registry6.example.com", + want: auth.Credential{ + Username: "username", + Password: "password", + }, + }, + { + name: "Auth overriding Username and password", + serverAddress: "registry7.example.com", + want: auth.Credential{ + Username: "username", + Password: "password", + }, + }, + { + name: "Not in auths", + serverAddress: "foo.example.com", + want: auth.EmptyCredential, + }, + { + name: "No record", + serverAddress: "registry999.example.com", + want: auth.EmptyCredential, + }, + } + for _, tt := range tests { + t.Run(tt.name+" ReadOnlyFileStore.Get()", func(t *testing.T) { + got, err := rofs.Get(ctx, tt.serverAddress) + if (err != nil) != tt.wantErr { + t.Errorf("ReadOnlyFileStore.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ReadOnlyFileStore.Get() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestReadOnlyFileStore_Get_emptyConfig(t *testing.T) { + ctx := context.Background() + const emptyValidJson = "{}" + rofs, err := NewReadOnlyFileStore(strings.NewReader(emptyValidJson)) + if err != nil { + t.Fatal("NewReadOnlyFileStore() error =", err) + } + + tests := []struct { + name string + serverAddress string + want auth.Credential + wantErr error + }{ + { + name: "Not found", + serverAddress: "registry.example.com", + want: auth.EmptyCredential, + wantErr: nil, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := rofs.Get(ctx, tt.serverAddress) + if !errors.Is(err, tt.wantErr) { + t.Errorf("ReadOnlyFileStore.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("ReadOnlyFileStore.Get() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/registry/remote/credentials/registry.go b/registry/remote/credentials/registry.go index 39735b77..e6c35fad 100644 --- a/registry/remote/credentials/registry.go +++ b/registry/remote/credentials/registry.go @@ -69,7 +69,7 @@ func Logout(ctx context.Context, store Store, registryName string) error { } // Credential returns a Credential() function that can be used by auth.Client. -func Credential(store Store) auth.CredentialFunc { +func Credential(store ReadOnlyStore) auth.CredentialFunc { return func(ctx context.Context, hostport string) (auth.Credential, error) { hostport = ServerAddressFromHostname(hostport) if hostport == "" { diff --git a/registry/remote/credentials/store.go b/registry/remote/credentials/store.go index e26a98ae..59bb03f6 100644 --- a/registry/remote/credentials/store.go +++ b/registry/remote/credentials/store.go @@ -37,10 +37,15 @@ const ( dockerConfigFileName = "config.json" ) -// Store is the interface that any credentials store must implement. -type Store interface { +// ReadOnlyStore is a read-only interface for credentials store. +type ReadOnlyStore interface { // Get retrieves credentials from the store for the given server address. Get(ctx context.Context, serverAddress string) (auth.Credential, error) +} + +// Store is the interface that any credentials store must implement. +type Store interface { + ReadOnlyStore // Put saves credentials into the store for the given server address. Put(ctx context.Context, serverAddress string, cred auth.Credential) error // Delete removes credentials from the store for the given server address.