diff --git a/registry/remote/credentials/internal/config/config.go b/registry/remote/credentials/internal/config/config.go index 20ee0743..3a898f22 100644 --- a/registry/remote/credentials/internal/config/config.go +++ b/registry/remote/credentials/internal/config/config.go @@ -167,7 +167,7 @@ func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) // can be stored as "https://registry.example.com/". var matched bool for addr, auth := range cfg.authsCache { - if toHostname(addr) == serverAddress { + if ToHostname(addr) == serverAddress { matched = true authCfgBytes = auth break @@ -319,12 +319,12 @@ func decodeAuth(authStr string) (username string, password string, err error) { return username, password, nil } -// toHostname normalizes a server address to just its hostname, removing +// ToHostname normalizes a server address to just its hostname, removing // the scheme and the path parts. // It is used to match keys in the auths map, which may be either stored as // hostname or as hostname including scheme (in legacy docker config files). // Reference: https://github.com/docker/cli/blob/v24.0.6/cli/config/credentials/file_store.go#L71 -func toHostname(addr string) string { +func ToHostname(addr string) string { addr = strings.TrimPrefix(addr, "http://") addr = strings.TrimPrefix(addr, "https://") addr, _, _ = strings.Cut(addr, "/") diff --git a/registry/remote/credentials/internal/config/config_test.go b/registry/remote/credentials/internal/config/config_test.go index a18eccac..326e620d 100644 --- a/registry/remote/credentials/internal/config/config_test.go +++ b/registry/remote/credentials/internal/config/config_test.go @@ -1444,7 +1444,7 @@ func Test_toHostname(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := toHostname(tt.addr); got != tt.want { + if got := ToHostname(tt.addr); got != tt.want { t.Errorf("toHostname() = %v, want %v", got, tt.want) } }) diff --git a/registry/remote/credentials/memory_store.go b/registry/remote/credentials/memory_store.go index 6eb7749b..7fdabb1e 100644 --- a/registry/remote/credentials/memory_store.go +++ b/registry/remote/credentials/memory_store.go @@ -17,9 +17,12 @@ package credentials import ( "context" + "encoding/json" + "fmt" "sync" "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config" ) // memoryStore is a store that keeps credentials in memory. @@ -32,6 +35,30 @@ func NewMemoryStore() Store { return &memoryStore{} } +// NewMemoryStoreFromDockerConfig creates a new in-memory credentials store from the given configuration. +// +// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties +func NewMemoryStoreFromDockerConfig(c []byte) (Store, error) { + cfg := struct { + Auths map[string]config.AuthConfig `json:"auths"` + }{} + if err := json.Unmarshal(c, &cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal auth field: %w: %v", config.ErrInvalidConfigFormat, err) + } + + s := &memoryStore{} + for addr, auth := range cfg.Auths { + // Normalize the auth key to hostname. + hostname := config.ToHostname(addr) + cred, err := auth.Credential() + if err != nil { + return nil, err + } + _, _ = s.store.LoadOrStore(hostname, cred) + } + return s, nil +} + // Get retrieves credentials from the store for the given server address. func (ms *memoryStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) { cred, found := ms.store.Load(serverAddress) diff --git a/registry/remote/credentials/memory_store_from_config_test.go b/registry/remote/credentials/memory_store_from_config_test.go new file mode 100644 index 00000000..f9a2874a --- /dev/null +++ b/registry/remote/credentials/memory_store_from_config_test.go @@ -0,0 +1,168 @@ +/* +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" + "os" + "reflect" + "testing" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config" +) + +func TestMemoryStore_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 = NewMemoryStoreFromDockerConfig(f) + if !errors.Is(err, config.ErrInvalidConfigFormat) { + t.Fatalf("Error: %s is expected", config.ErrInvalidConfigFormat) + } +} + +func TestMemoryStore_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) + } + cfg, err := NewMemoryStoreFromDockerConfig(f) + if err != nil { + t.Fatalf("NewMemoryStoreFromConfig() 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+" MemoryStore.Get()", func(t *testing.T) { + got, err := cfg.Get(ctx, tt.serverAddress) + if (err != nil) != tt.wantErr { + t.Errorf("MemoryStore.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("MemoryStore.Get() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestMemoryStore_Get_emptyConfig(t *testing.T) { + ctx := context.Background() + emptyValidJson := []byte("{}") + cfg, err := NewMemoryStoreFromDockerConfig(emptyValidJson) + if err != nil { + t.Fatal("NewMemoryStoreFromConfig() 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.Get(ctx, tt.serverAddress) + if !errors.Is(err, tt.wantErr) { + t.Errorf("MemoryStore.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("MemoryStore.Get() = %v, want %v", got, tt.want) + } + }) + } +}