diff --git a/registry/remote/credentials/file_store.go b/registry/remote/credentials/file_store.go index 7664cc2a..fb9a0ab6 100644 --- a/registry/remote/credentials/file_store.go +++ b/registry/remote/credentials/file_store.go @@ -19,6 +19,7 @@ import ( "context" "errors" "fmt" + "io" "strings" "oras.land/oras-go/v2/registry/remote/auth" @@ -44,6 +45,9 @@ var ( // ErrBadCredentialFormat is returned by Put() when the credential format // is bad. ErrBadCredentialFormat = errors.New("bad credential format") + // ErrReadOnlyStore is returned for operations + // Put(...) and Delete(...) for read-only store. + ErrReadOnlyStore = errors.New("cannot put credentials in read-only store") ) // NewFileStore creates a new file credentials store. @@ -95,3 +99,36 @@ func validateCredentialFormat(cred auth.Credential) error { } return nil } + +// 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 +} + +// NewFileStore 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.NewReadOnlyConfig(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) +} + +// Get always returns ErrReadOnlyStore. It's present to satisfy the Store interface. +func (fs *ReadOnlyFileStore) Put(_ context.Context, _ string, _ auth.Credential) error { + return ErrReadOnlyStore +} + +// Delete always returns ErrReadOnlyStore. It's present to satisfy the Store interface. +func (fs *ReadOnlyFileStore) Delete(_ context.Context, _ string) error { + return ErrReadOnlyStore +} diff --git a/registry/remote/credentials/file_store_test.go b/registry/remote/credentials/file_store_test.go index dccb7d05..7de4a354 100644 --- a/registry/remote/credentials/file_store_test.go +++ b/registry/remote/credentials/file_store_test.go @@ -16,6 +16,7 @@ limitations under the License. package credentials import ( + "bytes" "context" "encoding/json" "errors" @@ -25,6 +26,7 @@ import ( "testing" "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config" "oras.land/oras-go/v2/registry/remote/credentials/internal/config/configtest" ) @@ -81,23 +83,46 @@ func TestNewFileStore_badFormat(t *testing.T) { }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name+" FileStore", func(t *testing.T) { _, err := NewFileStore(tt.configPath) if (err != nil) != tt.wantErr { t.Errorf("NewFileStore() error = %v, wantErr %v", err, tt.wantErr) return } }) + t.Run(tt.name+" ReadOnlyFileStore", func(t *testing.T) { + f, err := os.Open(tt.configPath) + if err != nil { + t.Fatalf("failed to open file: %v", err) + } + defer f.Close() + + _, err = NewReadOnlyFileStore(f) + if (err != nil) != tt.wantErr { + t.Errorf("NewReadOnlyFileStore() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) } } -func TestFileStore_Get_validConfig(t *testing.T) { +func TestFileStoreAndReadOnlyFileStore_Get_validConfig(t *testing.T) { ctx := context.Background() - fs, err := NewFileStore("testdata/valid_auths_config.json") + const validAuthsConfigPath = "testdata/valid_auths_config.json" + fs, err := NewFileStore(validAuthsConfigPath) if err != nil { t.Fatal("NewFileStore() error =", err) } + f, err := os.ReadFile(validAuthsConfigPath) + 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 @@ -169,7 +194,7 @@ func TestFileStore_Get_validConfig(t *testing.T) { }, } for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { + t.Run(tt.name+" FileStore.Get()", func(t *testing.T) { got, err := fs.Get(ctx, tt.serverAddress) if (err != nil) != tt.wantErr { t.Errorf("FileStore.Get() error = %v, wantErr %v", err, tt.wantErr) @@ -179,6 +204,27 @@ func TestFileStore_Get_validConfig(t *testing.T) { t.Errorf("FileStore.Get() = %v, want %v", got, tt.want) } }) + 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_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) } } @@ -296,6 +342,23 @@ func TestFileStore_Get_notExistConfig(t *testing.T) { } } +func TestReadOnlyFileStore_Put_expectError(t *testing.T) { + const validAuthsConfigPath = "testdata/valid_auths_config.json" + f, err := os.ReadFile(validAuthsConfigPath) + 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) + } + err = rofs.Put(context.Background(), "registry.example.com", auth.Credential{}) + if !errors.Is(err, ErrReadOnlyStore) { + t.Fatalf("Error: %s is expected", ErrReadOnlyStore) + } +} + func TestFileStore_Put_notExistConfig(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") @@ -592,6 +655,23 @@ func TestFileStore_Put_passwordContainsColon(t *testing.T) { } } +func TestReadOnlyFileStore_Delete_expectError(t *testing.T) { + const validAuthsConfigPath = "testdata/valid_auths_config.json" + f, err := os.ReadFile(validAuthsConfigPath) + 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) + } + err = rofs.Delete(context.Background(), "registry.example.com") + if !errors.Is(err, ErrReadOnlyStore) { + t.Fatalf("Error: %s is expected", ErrReadOnlyStore) + } +} + func TestFileStore_Delete(t *testing.T) { tempDir := t.TempDir() configPath := filepath.Join(tempDir, "config.json") diff --git a/registry/remote/credentials/internal/config/config.go b/registry/remote/credentials/internal/config/config.go index 20ee0743..7c2229c9 100644 --- a/registry/remote/credentials/internal/config/config.go +++ b/registry/remote/credentials/internal/config/config.go @@ -160,13 +160,17 @@ func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) cfg.rwLock.RLock() defer cfg.rwLock.RUnlock() - authCfgBytes, ok := cfg.authsCache[serverAddress] + return getCredentialFromCache(cfg.authsCache, serverAddress) +} + +func getCredentialFromCache(authsCache map[string]json.RawMessage, serverAddress string) (auth.Credential, error) { + authCfgBytes, ok := 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 { + for addr, auth := range authsCache { if toHostname(addr) == serverAddress { matched = true authCfgBytes = auth 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..25d3db6f --- /dev/null +++ b/registry/remote/credentials/internal/config/readonly_config.go @@ -0,0 +1,67 @@ +/* +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]auth.Credential +} + +// NewReadOnlyConfig 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 NewReadOnlyConfig(reader io.Reader) (*ReadOnlyConfig, error) { + var content map[string]json.RawMessage + if err := json.NewDecoder(reader).Decode(&content); err != nil { + return nil, err + } + var authsCache map[string]json.RawMessage + if authsBytes, ok := content[configFieldAuths]; ok { + if err := json.Unmarshal(authsBytes, &authsCache); err != nil { + return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err) + } + } + + cfg := ReadOnlyConfig{ + auths: make(map[string]auth.Credential, len(authsCache)), + } + for serverAddress := range authsCache { + creds, err := getCredentialFromCache(authsCache, serverAddress) + if err != nil { + return nil, err + } + cfg.auths[serverAddress] = creds + } + + 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) { + if v, ok := cfg.auths[serverAddress]; ok { + return v, nil + } + return auth.EmptyCredential, nil +}