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..8f34ee80 100644 --- a/registry/remote/credentials/internal/config/config.go +++ b/registry/remote/credentials/internal/config/config.go @@ -160,28 +160,7 @@ func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) cfg.rwLock.RLock() defer cfg.rwLock.RUnlock() - authCfgBytes, ok := 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 - } - } - var authCfg AuthConfig - if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil { - return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err) - } - return authCfg.Credential() + return getCredentialFromAuthsRaw(cfg.authsCache, serverAddress) } // PutAuthConfig puts cred for serverAddress. diff --git a/registry/remote/credentials/internal/config/helpers.go b/registry/remote/credentials/internal/config/helpers.go new file mode 100644 index 00000000..bea531c2 --- /dev/null +++ b/registry/remote/credentials/internal/config/helpers.go @@ -0,0 +1,35 @@ +package config + +import ( + "encoding/json" + "fmt" + + "oras.land/oras-go/v2/registry/remote/auth" +) + +// getCredentialsFromCache is a helper function to get the credential for serverAddress from authsRaw where key +// is an address and value is a raw JSON content. +func getCredentialFromAuthsRaw(authsRaw map[string]json.RawMessage, serverAddress string) (auth.Credential, error) { + authCfgBytes, ok := authsRaw[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 authsRaw { + if toHostname(addr) == serverAddress { + matched = true + authCfgBytes = auth + break + } + } + if !matched { + return auth.EmptyCredential, nil + } + } + var authCfg AuthConfig + if err := json.Unmarshal(authCfgBytes, &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..ded2702a --- /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 +} + +// 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 content map[string]json.RawMessage + if err := json.NewDecoder(reader).Decode(&content); err != nil { + return nil, err + } + var authsRaw map[string]json.RawMessage + if authsBytes, ok := content[configFieldAuths]; ok { + if err := json.Unmarshal(authsBytes, &authsRaw); err != nil { + return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err) + } + } + + cfg := ReadOnlyConfig{ + auths: make(map[string]auth.Credential, len(authsRaw)), + } + for serverAddress := range authsRaw { + creds, err := getCredentialFromAuthsRaw(authsRaw, 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 +} diff --git a/registry/remote/credentials/readonly_file_store.go b/registry/remote/credentials/readonly_file_store.go new file mode 100644 index 00000000..b36dcee2 --- /dev/null +++ b/registry/remote/credentials/readonly_file_store.go @@ -0,0 +1,62 @@ +/* +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" + "errors" + "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 +} + +// ErrReadOnlyStore is returned for operations +// Put(...) and Delete(...) for read-only store. +var ErrReadOnlyStore = errors.New("cannot modify content of the read-only store") + +// 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) +} + +// 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 +}