diff --git a/example_test.go b/example_test.go index 4da0f8d5..f5829641 100644 --- a/example_test.go +++ b/example_test.go @@ -25,6 +25,7 @@ import ( "oras.land/oras-go/v2/content/oci" "oras.land/oras-go/v2/registry/remote" "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" "oras.land/oras-go/v2/registry/remote/retry" ) @@ -48,7 +49,7 @@ func Example_pullFilesFromRemoteRepository() { // Note: The below code can be omitted if authentication is not required repo.Client = &auth.Client{ Client: retry.DefaultClient, - Cache: auth.DefaultCache, + Cache: auth.NewCache(), Credential: auth.StaticCredential(reg, auth.Credential{ Username: "username", Password: "password", @@ -83,7 +84,7 @@ func Example_pullImageFromRemoteRepository() { // Note: The below code can be omitted if authentication is not required repo.Client = &auth.Client{ Client: retry.DefaultClient, - Cache: auth.DefaultCache, + Cache: auth.NewCache(), Credential: auth.StaticCredential(reg, auth.Credential{ Username: "username", Password: "password", @@ -99,6 +100,46 @@ func Example_pullImageFromRemoteRepository() { fmt.Println("manifest descriptor:", manifestDescriptor) } +// ExamplePullImageUsingDockerCredentials gives an example of pulling an image +// from a remote repository to an OCI Image layout folder using Docker +// credentials. +func Example_pullImageUsingDockerCredentials() { + // 0. Create an OCI layout store + store, err := oci.New("/tmp/oci-layout-root") + if err != nil { + panic(err) + } + + // 1. Connect to a remote repository + ctx := context.Background() + reg := "docker.io" + repo, err := remote.NewRepository(reg + "/user/my-repo") + if err != nil { + panic(err) + } + + // prepare authentication using Docker credentials + storeOpts := credentials.StoreOptions{} + credStore, err := credentials.NewStoreFromDocker(storeOpts) + if err != nil { + panic(err) + } + repo.Client = &auth.Client{ + Client: retry.DefaultClient, + Cache: auth.NewCache(), + Credential: credentials.Credential(credStore), // Use the credentials store + } + + // 2. Copy from the remote repository to the OCI layout store + tag := "latest" + manifestDescriptor, err := oras.Copy(ctx, repo, tag, store, tag, oras.DefaultCopyOptions) + if err != nil { + panic(err) + } + + fmt.Println("manifest pulled:", manifestDescriptor.Digest, manifestDescriptor.MediaType) +} + // ExamplePushFilesToRemoteRepository gives an example of pushing local files // to a remote repository. func Example_pushFilesToRemoteRepository() { @@ -148,14 +189,14 @@ func Example_pushFilesToRemoteRepository() { // Note: The below code can be omitted if authentication is not required repo.Client = &auth.Client{ Client: retry.DefaultClient, - Cache: auth.DefaultCache, + Cache: auth.NewCache(), Credential: auth.StaticCredential(reg, auth.Credential{ Username: "username", Password: "password", }), } - // 3. Copy from the file store to the remote repository + // 4. Copy from the file store to the remote repository _, err = oras.Copy(ctx, fs, tag, repo, tag, oras.DefaultCopyOptions) if err != nil { panic(err) diff --git a/registry/remote/auth/client.go b/registry/remote/auth/client.go index 37eb65ed..b4b0261a 100644 --- a/registry/remote/auth/client.go +++ b/registry/remote/auth/client.go @@ -54,16 +54,23 @@ var maxResponseBytes int64 = 128 * 1024 // 128 KiB // See also ClientID. var defaultClientID = "oras-go" +// CredentialFunc represents a function that resolves the credential for the +// given registry (i.e. host:port). +// +// [EmptyCredential] is a valid return value and should not be considered as +// an error. +type CredentialFunc func(ctx context.Context, hostport string) (Credential, error) + // StaticCredential specifies static credentials for the given host. -func StaticCredential(registry string, cred Credential) func(context.Context, string) (Credential, error) { +func StaticCredential(registry string, cred Credential) CredentialFunc { if registry == "docker.io" { // it is expected that traffic targeting "docker.io" will be redirected // to "registry-1.docker.io" // reference: https://github.com/moby/moby/blob/v24.0.0-beta.2/registry/config.go#L25-L48 registry = "registry-1.docker.io" } - return func(_ context.Context, target string) (Credential, error) { - if target == registry { + return func(_ context.Context, hostport string) (Credential, error) { + if hostport == registry { return cred, nil } return EmptyCredential, nil @@ -88,10 +95,10 @@ type Client struct { // Credential specifies the function for resolving the credential for the // given registry (i.e. host:port). - // `EmptyCredential` is a valid return value and should not be considered as + // EmptyCredential is a valid return value and should not be considered as // an error. - // If nil, the credential is always resolved to `EmptyCredential`. - Credential func(context.Context, string) (Credential, error) + // If nil, the credential is always resolved to EmptyCredential. + Credential CredentialFunc // Cache caches credentials for direct accessing the remote registry. // If nil, no cache is used. diff --git a/registry/remote/credentials/example_test.go b/registry/remote/credentials/example_test.go new file mode 100644 index 00000000..be8eece0 --- /dev/null +++ b/registry/remote/credentials/example_test.go @@ -0,0 +1,239 @@ +/* +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_test + +import ( + "context" + "fmt" + "net/http" + + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" + credentials "oras.land/oras-go/v2/registry/remote/credentials" +) + +func ExampleNewNativeStore() { + ns := credentials.NewNativeStore("pass") + + ctx := context.Background() + // save credentials into the store + err := ns.Put(ctx, "localhost:5000", auth.Credential{ + Username: "username-example", + Password: "password-example", + }) + if err != nil { + panic(err) + } + + // get credentials from the store + cred, err := ns.Get(ctx, "localhost:5000") + if err != nil { + panic(err) + } + fmt.Println(cred) + + // delete the credentials from the store + err = ns.Delete(ctx, "localhost:5000") + if err != nil { + panic(err) + } +} + +func ExampleNewFileStore() { + fs, err := credentials.NewFileStore("example/path/config.json") + if err != nil { + panic(err) + } + + ctx := context.Background() + // save credentials into the store + err = fs.Put(ctx, "localhost:5000", auth.Credential{ + Username: "username-example", + Password: "password-example", + }) + if err != nil { + panic(err) + } + + // get credentials from the store + cred, err := fs.Get(ctx, "localhost:5000") + if err != nil { + panic(err) + } + fmt.Println(cred) + + // delete the credentials from the store + err = fs.Delete(ctx, "localhost:5000") + if err != nil { + panic(err) + } +} + +func ExampleNewStore() { + // NewStore returns a Store based on the given configuration file. It will + // automatically determine which Store (file store or native store) to use. + // If the native store is not available, you can save your credentials in + // the configuration file by specifying AllowPlaintextPut: true, but keep + // in mind that this is an unsafe workaround. + // See the documentation for details. + store, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{ + AllowPlaintextPut: true, + }) + if err != nil { + panic(err) + } + + ctx := context.Background() + // save credentials into the store + err = store.Put(ctx, "localhost:5000", auth.Credential{ + Username: "username-example", + Password: "password-example", + }) + if err != nil { + panic(err) + } + + // get credentials from the store + cred, err := store.Get(ctx, "localhost:5000") + if err != nil { + panic(err) + } + fmt.Println(cred) + + // delete the credentials from the store + err = store.Delete(ctx, "localhost:5000") + if err != nil { + panic(err) + } +} + +func ExampleNewStoreFromDocker() { + ds, err := credentials.NewStoreFromDocker(credentials.StoreOptions{ + AllowPlaintextPut: true, + }) + if err != nil { + panic(err) + } + + ctx := context.Background() + // save credentials into the store + err = ds.Put(ctx, "localhost:5000", auth.Credential{ + Username: "username-example", + Password: "password-example", + }) + if err != nil { + panic(err) + } + + // get credentials from the store + cred, err := ds.Get(ctx, "localhost:5000") + if err != nil { + panic(err) + } + fmt.Println(cred) + + // delete the credentials from the store + err = ds.Delete(ctx, "localhost:5000") + if err != nil { + panic(err) + } +} + +func ExampleNewStoreWithFallbacks_configAsPrimaryStoreDockerAsFallback() { + primaryStore, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{ + AllowPlaintextPut: true, + }) + if err != nil { + panic(err) + } + fallbackStore, err := credentials.NewStoreFromDocker(credentials.StoreOptions{}) + sf := credentials.NewStoreWithFallbacks(primaryStore, fallbackStore) + + ctx := context.Background() + // save credentials into the store + err = sf.Put(ctx, "localhost:5000", auth.Credential{ + Username: "username-example", + Password: "password-example", + }) + if err != nil { + panic(err) + } + + // get credentials from the store + cred, err := sf.Get(ctx, "localhost:5000") + if err != nil { + panic(err) + } + fmt.Println(cred) + + // delete the credentials from the store + err = sf.Delete(ctx, "localhost:5000") + if err != nil { + panic(err) + } +} + +func ExampleLogin() { + store, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{ + AllowPlaintextPut: true, + }) + if err != nil { + panic(err) + } + registry, err := remote.NewRegistry("localhost:5000") + if err != nil { + panic(err) + } + cred := auth.Credential{ + Username: "username-example", + Password: "password-example", + } + err = credentials.Login(context.Background(), store, registry, cred) + if err != nil { + panic(err) + } + fmt.Println("Login succeeded") +} + +func ExampleLogout() { + store, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{}) + if err != nil { + panic(err) + } + err = credentials.Logout(context.Background(), store, "localhost:5000") + if err != nil { + panic(err) + } + fmt.Println("Logout succeeded") +} + +func ExampleCredential() { + store, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{}) + if err != nil { + panic(err) + } + + client := auth.DefaultClient + client.Credential = credentials.Credential(store) + + request, err := http.NewRequest(http.MethodGet, "localhost:5000", nil) + if err != nil { + panic(err) + } + + _, err = client.Do(request) + if err != nil { + panic(err) + } +} diff --git a/registry/remote/credentials/file_store.go b/registry/remote/credentials/file_store.go new file mode 100644 index 00000000..7664cc2a --- /dev/null +++ b/registry/remote/credentials/file_store.go @@ -0,0 +1,97 @@ +/* +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" + "fmt" + "strings" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config" +) + +// FileStore implements a credentials store using the docker configuration file +// to keep the credentials in plain-text. +// +// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties +type FileStore struct { + // DisablePut disables putting credentials in plaintext. + // If DisablePut is set to true, Put() will return ErrPlaintextPutDisabled. + DisablePut bool + + config *config.Config +} + +var ( + // ErrPlaintextPutDisabled is returned by Put() when DisablePut is set + // to true. + ErrPlaintextPutDisabled = errors.New("putting plaintext credentials is disabled") + // ErrBadCredentialFormat is returned by Put() when the credential format + // is bad. + ErrBadCredentialFormat = errors.New("bad credential format") +) + +// NewFileStore creates a new file credentials store. +// +// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties +func NewFileStore(configPath string) (*FileStore, error) { + cfg, err := config.Load(configPath) + if err != nil { + return nil, err + } + return newFileStore(cfg), nil +} + +// newFileStore creates a file credentials store based on the given config instance. +func newFileStore(cfg *config.Config) *FileStore { + return &FileStore{config: cfg} +} + +// Get retrieves credentials from the store for the given server address. +func (fs *FileStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) { + return fs.config.GetCredential(serverAddress) +} + +// Put saves credentials into the store for the given server address. +// Returns ErrPlaintextPutDisabled if fs.DisablePut is set to true. +func (fs *FileStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error { + if fs.DisablePut { + return ErrPlaintextPutDisabled + } + if err := validateCredentialFormat(cred); err != nil { + return err + } + + return fs.config.PutCredential(serverAddress, cred) +} + +// Delete removes credentials from the store for the given server address. +func (fs *FileStore) Delete(_ context.Context, serverAddress string) error { + return fs.config.DeleteCredential(serverAddress) +} + +// validateCredentialFormat validates the format of cred. +func validateCredentialFormat(cred auth.Credential) error { + if strings.ContainsRune(cred.Username, ':') { + // Username and password will be encoded in the base64(username:password) + // format in the file. The decoded result will be wrong if username + // contains colon(s). + return fmt.Errorf("%w: colons(:) are not allowed in username", ErrBadCredentialFormat) + } + return nil +} diff --git a/registry/remote/credentials/file_store_test.go b/registry/remote/credentials/file_store_test.go new file mode 100644 index 00000000..dccb7d05 --- /dev/null +++ b/registry/remote/credentials/file_store_test.go @@ -0,0 +1,910 @@ +/* +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" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config/configtest" +) + +func TestNewFileStore_badPath(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + configPath string + wantErr bool + }{ + { + name: "Path is a directory", + configPath: tempDir, + wantErr: true, + }, + { + name: "Empty file name", + configPath: filepath.Join(tempDir, ""), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewFileStore(tt.configPath) + if (err != nil) != tt.wantErr { + t.Errorf("NewFileStore() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestNewFileStore_badFormat(t *testing.T) { + tests := []struct { + name string + configPath string + wantErr bool + }{ + { + name: "Bad JSON format", + configPath: "testdata/bad_config", + wantErr: true, + }, + { + name: "Invalid auths format", + configPath: "testdata/invalid_auths_config.json", + wantErr: true, + }, + { + name: "No auths field", + configPath: "testdata/no_auths_config.json", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := NewFileStore(tt.configPath) + if (err != nil) != tt.wantErr { + t.Errorf("NewFileStore() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestFileStore_Get_validConfig(t *testing.T) { + ctx := context.Background() + fs, err := NewFileStore("testdata/valid_auths_config.json") + if err != nil { + t.Fatal("NewFileStore() 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 := fs.Get(ctx, tt.serverAddress) + if (err != nil) != tt.wantErr { + t.Errorf("FileStore.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FileStore.Get() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFileStore_Get_invalidConfig(t *testing.T) { + ctx := context.Background() + fs, err := NewFileStore("testdata/invalid_auths_entry_config.json") + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + + tests := []struct { + name string + serverAddress string + want auth.Credential + wantErr bool + }{ + { + name: "Invalid auth encode", + serverAddress: "registry1.example.com", + want: auth.EmptyCredential, + wantErr: true, + }, + { + name: "Invalid auths format", + serverAddress: "registry2.example.com", + want: auth.EmptyCredential, + wantErr: true, + }, + { + name: "Invalid type", + serverAddress: "registry3.example.com", + want: auth.EmptyCredential, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, 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) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FileStore.Get() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFileStore_Get_emptyConfig(t *testing.T) { + ctx := context.Background() + fs, err := NewFileStore("testdata/empty_config.json") + if err != nil { + t.Fatal("NewFileStore() 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 := fs.Get(ctx, tt.serverAddress) + if !errors.Is(err, tt.wantErr) { + t.Errorf("FileStore.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FileStore.Get() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFileStore_Get_notExistConfig(t *testing.T) { + ctx := context.Background() + fs, err := NewFileStore("whatever") + if err != nil { + t.Fatal("NewFileStore() 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 := fs.Get(ctx, tt.serverAddress) + if !errors.Is(err, tt.wantErr) { + t.Errorf("FileStore.Get() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("FileStore.Get() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestFileStore_Put_notExistConfig(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + + server := "test.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + + // test put + if err := fs.Put(ctx, server, cred); err != nil { + t.Fatalf("FileStore.Put() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + + var cfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&cfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + want := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: "refresh_token", + RegistryToken: "access_token", + }, + }, + } + if !reflect.DeepEqual(cfg, want) { + t.Errorf("Decoded config = %v, want %v", cfg, want) + } + + // verify get + got, err := fs.Get(ctx, server) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := cred; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get() = %v, want %v", got, want) + } +} + +func TestFileStore_Put_addNew(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + // prepare test content + server1 := "registry1.example.com" + cred1 := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server1: { + SomeAuthField: "whatever", + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: cred1.RefreshToken, + RegistryToken: cred1.AccessToken, + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // test put + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + server2 := "registry2.example.com" + cred2 := auth.Credential{ + Username: "username_2", + Password: "password_2", + RefreshToken: "refresh_token_2", + AccessToken: "access_token_2", + } + if err := fs.Put(ctx, server2, cred2); err != nil { + t.Fatalf("FileStore.Put() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server1: { + SomeAuthField: "whatever", + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: cred1.RefreshToken, + RegistryToken: cred1.AccessToken, + }, + server2: { + Auth: "dXNlcm5hbWVfMjpwYXNzd29yZF8y", + IdentityToken: "refresh_token_2", + RegistryToken: "access_token_2", + }, + }, + SomeConfigField: cfg.SomeConfigField, + } + if !reflect.DeepEqual(gotCfg, wantCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) + } + + // verify get + got, err := fs.Get(ctx, server1) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := cred1; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server1, got, want) + } + + got, err = fs.Get(ctx, server2) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := cred2; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server2, got, want) + } +} + +func TestFileStore_Put_updateOld(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + // prepare test content + server := "registry.example.com" + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: { + SomeAuthField: "whatever", + Username: "foo", + Password: "bar", + IdentityToken: "refresh_token", + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // test put + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + cred := auth.Credential{ + Username: "username", + Password: "password", + AccessToken: "access_token", + } + if err := fs.Put(ctx, server, cred); err != nil { + t.Fatalf("FileStore.Put() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + RegistryToken: "access_token", + }, + }, + SomeConfigField: cfg.SomeConfigField, + } + if !reflect.DeepEqual(gotCfg, wantCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) + } + + // verify get + got, err := fs.Get(ctx, server) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := cred; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server, got, want) + } +} + +func TestFileStore_Put_disablePut(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + fs.DisablePut = true + + server := "test.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + err = fs.Put(ctx, server, cred) + if wantErr := ErrPlaintextPutDisabled; !errors.Is(err, wantErr) { + t.Errorf("FileStore.Put() error = %v, wantErr %v", err, wantErr) + } +} + +func TestFileStore_Put_usernameContainsColon(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + serverAddr := "test.example.com" + cred := auth.Credential{ + Username: "x:y", + Password: "z", + } + if err := fs.Put(ctx, serverAddr, cred); err == nil { + t.Fatal("FileStore.Put() error is nil, want", ErrBadCredentialFormat) + } +} + +func TestFileStore_Put_passwordContainsColon(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + serverAddr := "test.example.com" + cred := auth.Credential{ + Username: "y", + Password: "y:z", + } + if err := fs.Put(ctx, serverAddr, cred); err != nil { + t.Fatal("FileStore.Put() error =", err) + } + got, err := fs.Get(ctx, serverAddr) + if err != nil { + t.Fatal("FileStore.Get() error =", err) + } + if !reflect.DeepEqual(got, cred) { + t.Errorf("FileStore.Get() = %v, want %v", got, cred) + } +} + +func TestFileStore_Delete(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + // prepare test content + server1 := "registry1.example.com" + cred1 := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + server2 := "registry2.example.com" + cred2 := auth.Credential{ + Username: "username_2", + Password: "password_2", + RefreshToken: "refresh_token_2", + AccessToken: "access_token_2", + } + + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server1: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: cred1.RefreshToken, + RegistryToken: cred1.AccessToken, + }, + server2: { + Auth: "dXNlcm5hbWVfMjpwYXNzd29yZF8y", + IdentityToken: "refresh_token_2", + RegistryToken: "access_token_2", + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + // test get + got, err := fs.Get(ctx, server1) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := cred1; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server1, got, want) + } + got, err = fs.Get(ctx, server2) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := cred2; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server2, got, want) + } + + // test delete + if err := fs.Delete(ctx, server1); err != nil { + t.Fatalf("FileStore.Delete() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server2: cfg.AuthConfigs[server2], + }, + SomeConfigField: cfg.SomeConfigField, + } + if !reflect.DeepEqual(gotCfg, wantCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) + } + + // test get again + got, err = fs.Get(ctx, server1) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := auth.EmptyCredential; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server1, got, want) + } + got, err = fs.Get(ctx, server2) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := cred2; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server2, got, want) + } +} + +func TestFileStore_Delete_lastConfig(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + // prepare test content + server := "registry1.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: cred.RefreshToken, + RegistryToken: cred.AccessToken, + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + // test get + got, err := fs.Get(ctx, server) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := cred; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server, got, want) + } + + // test delete + if err := fs.Delete(ctx, server); err != nil { + t.Fatalf("FileStore.Delete() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{}, + SomeConfigField: cfg.SomeConfigField, + } + if !reflect.DeepEqual(gotCfg, wantCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) + } + + // test get again + got, err = fs.Get(ctx, server) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := auth.EmptyCredential; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server, got, want) + } +} + +func TestFileStore_Delete_notExistRecord(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + // prepare test content + server := "registry1.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: cred.RefreshToken, + RegistryToken: cred.AccessToken, + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + // test get + got, err := fs.Get(ctx, server) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := cred; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server, got, want) + } + + // test delete + if err := fs.Delete(ctx, "test.example.com"); err != nil { + t.Fatalf("FileStore.Delete() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: cfg.AuthConfigs[server], + }, + SomeConfigField: cfg.SomeConfigField, + } + if !reflect.DeepEqual(gotCfg, wantCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) + } + + // test get again + got, err = fs.Get(ctx, server) + if err != nil { + t.Fatalf("FileStore.Get() error = %v", err) + } + if want := cred; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server, got, want) + } +} + +func TestFileStore_Delete_notExistConfig(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + ctx := context.Background() + + fs, err := NewFileStore(configPath) + if err != nil { + t.Fatal("NewFileStore() error =", err) + } + + server := "test.example.com" + // test delete + if err := fs.Delete(ctx, server); err != nil { + t.Fatalf("FileStore.Delete() error = %v", err) + } + + // verify config file is not created + _, err = os.Stat(configPath) + if wantErr := os.ErrNotExist; !errors.Is(err, wantErr) { + t.Errorf("Stat(%s) error = %v, wantErr %v", configPath, err, wantErr) + } +} + +func Test_validateCredentialFormat(t *testing.T) { + tests := []struct { + name string + cred auth.Credential + wantErr error + }{ + { + name: "Username contains colon", + cred: auth.Credential{ + Username: "x:y", + Password: "z", + }, + wantErr: ErrBadCredentialFormat, + }, + { + name: "Password contains colon", + cred: auth.Credential{ + Username: "x", + Password: "y:z", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := validateCredentialFormat(tt.cred); !errors.Is(err, tt.wantErr) { + t.Errorf("validateCredentialFormat() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} diff --git a/registry/remote/credentials/internal/config/config.go b/registry/remote/credentials/internal/config/config.go new file mode 100644 index 00000000..ff234291 --- /dev/null +++ b/registry/remote/credentials/internal/config/config.go @@ -0,0 +1,302 @@ +/* +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" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/ioutil" +) + +const ( + // configFieldAuths is the "auths" field in the config file. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19 + configFieldAuths = "auths" + // configFieldCredentialsStore is the "credsStore" field in the config file. + configFieldCredentialsStore = "credsStore" + // configFieldCredentialHelpers is the "credHelpers" field in the config file. + configFieldCredentialHelpers = "credHelpers" +) + +// ErrInvalidConfigFormat is returned when the config format is invalid. +var ErrInvalidConfigFormat = errors.New("invalid config format") + +// AuthConfig contains authorization information for connecting to a Registry. +// References: +// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L45 +// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/types/authconfig.go#L3-L22 +type AuthConfig struct { + // Auth is a base64-encoded string of "{username}:{password}". + Auth string `json:"auth,omitempty"` + // IdentityToken is used to authenticate the user and get an access token + // for the registry. + IdentityToken string `json:"identitytoken,omitempty"` + // RegistryToken is a bearer token to be sent to a registry. + RegistryToken string `json:"registrytoken,omitempty"` + + Username string `json:"username,omitempty"` // legacy field for compatibility + Password string `json:"password,omitempty"` // legacy field for compatibility +} + +// NewAuthConfig creates an authConfig based on cred. +func NewAuthConfig(cred auth.Credential) AuthConfig { + return AuthConfig{ + Auth: encodeAuth(cred.Username, cred.Password), + IdentityToken: cred.RefreshToken, + RegistryToken: cred.AccessToken, + } +} + +// Credential returns an auth.Credential based on ac. +func (ac AuthConfig) Credential() (auth.Credential, error) { + cred := auth.Credential{ + Username: ac.Username, + Password: ac.Password, + RefreshToken: ac.IdentityToken, + AccessToken: ac.RegistryToken, + } + if ac.Auth != "" { + var err error + // override username and password + cred.Username, cred.Password, err = decodeAuth(ac.Auth) + if err != nil { + return auth.EmptyCredential, fmt.Errorf("failed to decode auth field: %w: %v", ErrInvalidConfigFormat, err) + } + } + return cred, nil +} + +// Config represents a docker configuration file. +// References: +// - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties +// - https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44 +type Config struct { + // path is the path to the config file. + path string + // rwLock is a read-write-lock for the file store. + rwLock sync.RWMutex + // content is the content of the config file. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L17-L44 + content map[string]json.RawMessage + // authsCache is a cache of the auths field of the config. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L19 + authsCache map[string]json.RawMessage + // credentialsStore is the credsStore field of the config. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L28 + credentialsStore string + // credentialHelpers is the credHelpers field of the config. + // Reference: https://github.com/docker/cli/blob/v24.0.0-beta.2/cli/config/configfile/file.go#L29 + credentialHelpers map[string]string +} + +// Load loads Config from the given config path. +func Load(configPath string) (*Config, error) { + cfg := &Config{path: configPath} + configFile, err := os.Open(configPath) + if err != nil { + if os.IsNotExist(err) { + // init content and caches if the content file does not exist + cfg.content = make(map[string]json.RawMessage) + cfg.authsCache = make(map[string]json.RawMessage) + return cfg, nil + } + return nil, fmt.Errorf("failed to open config file at %s: %w", configPath, err) + } + defer configFile.Close() + + // decode config content if the config file exists + if err := json.NewDecoder(configFile).Decode(&cfg.content); err != nil { + return nil, fmt.Errorf("failed to decode config file at %s: %w: %v", configPath, ErrInvalidConfigFormat, err) + } + + if credsStoreBytes, ok := cfg.content[configFieldCredentialsStore]; ok { + if err := json.Unmarshal(credsStoreBytes, &cfg.credentialsStore); err != nil { + return nil, fmt.Errorf("failed to unmarshal creds store field: %w: %v", ErrInvalidConfigFormat, err) + } + } + + if credHelpersBytes, ok := cfg.content[configFieldCredentialHelpers]; ok { + if err := json.Unmarshal(credHelpersBytes, &cfg.credentialHelpers); err != nil { + return nil, fmt.Errorf("failed to unmarshal cred helpers field: %w: %v", ErrInvalidConfigFormat, err) + } + } + + if authsBytes, ok := cfg.content[configFieldAuths]; ok { + if err := json.Unmarshal(authsBytes, &cfg.authsCache); err != nil { + return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err) + } + } + if cfg.authsCache == nil { + cfg.authsCache = make(map[string]json.RawMessage) + } + + return cfg, nil +} + +// GetAuthConfig returns an auth.Credential for serverAddress. +func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error) { + cfg.rwLock.RLock() + defer cfg.rwLock.RUnlock() + + authCfgBytes, ok := cfg.authsCache[serverAddress] + if !ok { + 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() +} + +// PutAuthConfig puts cred for serverAddress. +func (cfg *Config) PutCredential(serverAddress string, cred auth.Credential) error { + cfg.rwLock.Lock() + defer cfg.rwLock.Unlock() + + authCfg := NewAuthConfig(cred) + authCfgBytes, err := json.Marshal(authCfg) + if err != nil { + return fmt.Errorf("failed to marshal auth field: %w", err) + } + cfg.authsCache[serverAddress] = authCfgBytes + return cfg.saveFile() +} + +// DeleteAuthConfig deletes the corresponding credential for serverAddress. +func (cfg *Config) DeleteCredential(serverAddress string) error { + cfg.rwLock.Lock() + defer cfg.rwLock.Unlock() + + if _, ok := cfg.authsCache[serverAddress]; !ok { + // no ops + return nil + } + delete(cfg.authsCache, serverAddress) + return cfg.saveFile() +} + +// GetCredentialHelper returns the credential helpers for serverAddress. +func (cfg *Config) GetCredentialHelper(serverAddress string) string { + return cfg.credentialHelpers[serverAddress] +} + +// CredentialsStore returns the configured credentials store. +func (cfg *Config) CredentialsStore() string { + cfg.rwLock.RLock() + defer cfg.rwLock.RUnlock() + + return cfg.credentialsStore +} + +// SetCredentialsStore puts the configured credentials store. +func (cfg *Config) SetCredentialsStore(credsStore string) error { + cfg.rwLock.Lock() + defer cfg.rwLock.Unlock() + + cfg.credentialsStore = credsStore + return cfg.saveFile() +} + +// IsAuthConfigured returns whether there is authentication configured in this +// config file or not. +func (cfg *Config) IsAuthConfigured() bool { + return cfg.credentialsStore != "" || + len(cfg.credentialHelpers) > 0 || + len(cfg.authsCache) > 0 +} + +// saveFile saves Config into the file. +func (cfg *Config) saveFile() (returnErr error) { + // marshal content + // credentialHelpers is skipped as it's never set + if cfg.credentialsStore != "" { + credsStoreBytes, err := json.Marshal(cfg.credentialsStore) + if err != nil { + return fmt.Errorf("failed to marshal creds store: %w", err) + } + cfg.content[configFieldCredentialsStore] = credsStoreBytes + } else { + // omit empty + delete(cfg.content, configFieldCredentialsStore) + } + authsBytes, err := json.Marshal(cfg.authsCache) + if err != nil { + return fmt.Errorf("failed to marshal credentials: %w", err) + } + cfg.content[configFieldAuths] = authsBytes + jsonBytes, err := json.MarshalIndent(cfg.content, "", "\t") + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + // write the content to a ingest file for atomicity + configDir := filepath.Dir(cfg.path) + if err := os.MkdirAll(configDir, 0700); err != nil { + return fmt.Errorf("failed to make directory %s: %w", configDir, err) + } + ingest, err := ioutil.Ingest(configDir, bytes.NewReader(jsonBytes)) + if err != nil { + return fmt.Errorf("failed to save config file: %w", err) + } + defer func() { + if returnErr != nil { + // clean up the ingest file in case of error + os.Remove(ingest) + } + }() + + // overwrite the config file + if err := os.Rename(ingest, cfg.path); err != nil { + return fmt.Errorf("failed to save config file: %w", err) + } + return nil +} + +// encodeAuth base64-encodes username and password into base64(username:password). +func encodeAuth(username, password string) string { + if username == "" && password == "" { + return "" + } + return base64.StdEncoding.EncodeToString([]byte(username + ":" + password)) +} + +// decodeAuth decodes a base64 encoded string and returns username and password. +func decodeAuth(authStr string) (username string, password string, err error) { + if authStr == "" { + return "", "", nil + } + + decoded, err := base64.StdEncoding.DecodeString(authStr) + if err != nil { + return "", "", err + } + decodedStr := string(decoded) + username, password, ok := strings.Cut(decodedStr, ":") + if !ok { + return "", "", fmt.Errorf("auth '%s' does not conform the base64(username:password) format", decodedStr) + } + return username, password, nil +} diff --git a/registry/remote/credentials/internal/config/config_test.go b/registry/remote/credentials/internal/config/config_test.go new file mode 100644 index 00000000..455f23dd --- /dev/null +++ b/registry/remote/credentials/internal/config/config_test.go @@ -0,0 +1,1316 @@ +/* +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" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config/configtest" +) + +func TestLoad_badPath(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + configPath string + wantErr bool + }{ + { + name: "Path is a directory", + configPath: tempDir, + wantErr: true, + }, + { + name: "Empty file name", + configPath: filepath.Join(tempDir, ""), + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Load(tt.configPath) + if (err != nil) != tt.wantErr { + t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestLoad_badFormat(t *testing.T) { + tests := []struct { + name string + configPath string + wantErr bool + }{ + { + name: "Bad JSON format", + configPath: "../../testdata/bad_config", + wantErr: true, + }, + { + name: "Invalid auths format", + configPath: "../../testdata/invalid_auths_config.json", + wantErr: true, + }, + { + name: "No auths field", + configPath: "../../testdata/no_auths_config.json", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Load(tt.configPath) + if (err != nil) != tt.wantErr { + t.Errorf("Load() error = %v, wantErr %v", err, tt.wantErr) + return + } + }) + } +} + +func TestConfig_GetCredential_validConfig(t *testing.T) { + cfg, err := Load("../../testdata/valid_auths_config.json") + if err != nil { + t.Fatal("Load() 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("Config.GetCredential() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Config.GetCredential() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfig_GetCredential_invalidConfig(t *testing.T) { + cfg, err := Load("../../testdata/invalid_auths_entry_config.json") + if err != nil { + t.Fatal("Load() error =", err) + } + + tests := []struct { + name string + serverAddress string + want auth.Credential + wantErr bool + }{ + { + name: "Invalid auth encode", + serverAddress: "registry1.example.com", + want: auth.EmptyCredential, + wantErr: true, + }, + { + name: "Invalid auths format", + serverAddress: "registry2.example.com", + want: auth.EmptyCredential, + wantErr: true, + }, + { + name: "Invalid type", + serverAddress: "registry3.example.com", + want: auth.EmptyCredential, + wantErr: true, + }, + } + 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("Config.GetCredential() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Config.GetCredential() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfig_GetCredential_emptyConfig(t *testing.T) { + cfg, err := Load("../../testdata/empty_config.json") + if err != nil { + t.Fatal("Load() 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("Config.GetCredential() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Config.GetCredential() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfig_GetCredential_notExistConfig(t *testing.T) { + cfg, err := Load("whatever") + if err != nil { + t.Fatal("Load() 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("Config.GetCredential() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("Config.GetCredential() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfig_PutCredential_notExistConfig(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + + cfg, err := Load(configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + + server := "test.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + + // test put + if err := cfg.PutCredential(server, cred); err != nil { + t.Fatalf("Config.PutCredential() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + + var testCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&testCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + want := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: "refresh_token", + RegistryToken: "access_token", + }, + }, + } + if !reflect.DeepEqual(testCfg, want) { + t.Errorf("Decoded config = %v, want %v", testCfg, want) + } + + // verify get + got, err := cfg.GetCredential(server) + if err != nil { + t.Fatalf("Config.GetCredential() error = %v", err) + } + if want := cred; !reflect.DeepEqual(got, want) { + t.Errorf("Config.GetCredential() = %v, want %v", got, want) + } +} + +func TestConfig_PutCredential_addNew(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + // prepare test content + server1 := "registry1.example.com" + cred1 := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + + testCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server1: { + SomeAuthField: "whatever", + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: cred1.RefreshToken, + RegistryToken: cred1.AccessToken, + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(testCfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // test put + cfg, err := Load(configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + server2 := "registry2.example.com" + cred2 := auth.Credential{ + Username: "username_2", + Password: "password_2", + RefreshToken: "refresh_token_2", + AccessToken: "access_token_2", + } + if err := cfg.PutCredential(server2, cred2); err != nil { + t.Fatalf("Config.PutCredential() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantTestCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server1: { + SomeAuthField: "whatever", + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: cred1.RefreshToken, + RegistryToken: cred1.AccessToken, + }, + server2: { + Auth: "dXNlcm5hbWVfMjpwYXNzd29yZF8y", + IdentityToken: "refresh_token_2", + RegistryToken: "access_token_2", + }, + }, + SomeConfigField: testCfg.SomeConfigField, + } + if !reflect.DeepEqual(gotCfg, wantTestCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, wantTestCfg) + } + + // verify get + got, err := cfg.GetCredential(server1) + if err != nil { + t.Fatalf("Config.GetCredential() error = %v", err) + } + if want := cred1; !reflect.DeepEqual(got, want) { + t.Errorf("Config.GetCredential(%s) = %v, want %v", server1, got, want) + } + + got, err = cfg.GetCredential(server2) + if err != nil { + t.Fatalf("Config.GetCredential() error = %v", err) + } + if want := cred2; !reflect.DeepEqual(got, want) { + t.Errorf("Config.GetCredential(%s) = %v, want %v", server2, got, want) + } +} + +func TestConfig_PutCredential_updateOld(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + + // prepare test content + server := "registry.example.com" + testCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: { + SomeAuthField: "whatever", + Username: "foo", + Password: "bar", + IdentityToken: "refresh_token", + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(testCfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // test put + cfg, err := Load(configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + cred := auth.Credential{ + Username: "username", + Password: "password", + AccessToken: "access_token", + } + if err := cfg.PutCredential(server, cred); err != nil { + t.Fatalf("Config.PutCredential() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + RegistryToken: "access_token", + }, + }, + SomeConfigField: testCfg.SomeConfigField, + } + if !reflect.DeepEqual(gotCfg, wantCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) + } + + // verify get + got, err := cfg.GetCredential(server) + if err != nil { + t.Fatalf("Config.GetCredential() error = %v", err) + } + if want := cred; !reflect.DeepEqual(got, want) { + t.Errorf("Config.GetCredential(%s) = %v, want %v", server, got, want) + } +} + +func TestConfig_DeleteCredential(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + + // prepare test content + server1 := "registry1.example.com" + cred1 := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + server2 := "registry2.example.com" + cred2 := auth.Credential{ + Username: "username_2", + Password: "password_2", + RefreshToken: "refresh_token_2", + AccessToken: "access_token_2", + } + + testCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server1: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: cred1.RefreshToken, + RegistryToken: cred1.AccessToken, + }, + server2: { + Auth: "dXNlcm5hbWVfMjpwYXNzd29yZF8y", + IdentityToken: "refresh_token_2", + RegistryToken: "access_token_2", + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(testCfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + // test get + got, err := cfg.GetCredential(server1) + if err != nil { + t.Fatalf("FileStore.GetCredential() error = %v", err) + } + if want := cred1; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.GetCredential(%s) = %v, want %v", server1, got, want) + } + got, err = cfg.GetCredential(server2) + if err != nil { + t.Fatalf("FileStore.GetCredential() error = %v", err) + } + if want := cred2; !reflect.DeepEqual(got, want) { + t.Errorf("FileStore.Get(%s) = %v, want %v", server2, got, want) + } + + // test delete + if err := cfg.DeleteCredential(server1); err != nil { + t.Fatalf("Config.DeleteCredential() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotTestCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotTestCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantTestCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server2: testCfg.AuthConfigs[server2], + }, + SomeConfigField: testCfg.SomeConfigField, + } + if !reflect.DeepEqual(gotTestCfg, wantTestCfg) { + t.Errorf("Decoded config = %v, want %v", gotTestCfg, wantTestCfg) + } + + // test get again + got, err = cfg.GetCredential(server1) + if err != nil { + t.Fatalf("Config.GetCredential() error = %v", err) + } + if want := auth.EmptyCredential; !reflect.DeepEqual(got, want) { + t.Errorf("Config.GetCredential(%s) = %v, want %v", server1, got, want) + } + got, err = cfg.GetCredential(server2) + if err != nil { + t.Fatalf("Config.GetCredential() error = %v", err) + } + if want := cred2; !reflect.DeepEqual(got, want) { + t.Errorf("Config.GetCredential(%s) = %v, want %v", server2, got, want) + } +} + +func TestConfig_DeleteCredential_lastConfig(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + + // prepare test content + server := "registry1.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + + testCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: cred.RefreshToken, + RegistryToken: cred.AccessToken, + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(testCfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + // test get + got, err := cfg.GetCredential(server) + if err != nil { + t.Fatalf("Config.GetCredential() error = %v", err) + } + if want := cred; !reflect.DeepEqual(got, want) { + t.Errorf("Config.GetCredential(%s) = %v, want %v", server, got, want) + } + + // test delete + if err := cfg.DeleteCredential(server); err != nil { + t.Fatalf("Config.DeleteCredential() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotTestCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotTestCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantTestCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{}, + SomeConfigField: testCfg.SomeConfigField, + } + if !reflect.DeepEqual(gotTestCfg, wantTestCfg) { + t.Errorf("Decoded config = %v, want %v", gotTestCfg, wantTestCfg) + } + + // test get again + got, err = cfg.GetCredential(server) + if err != nil { + t.Fatalf("Config.GetCredential() error = %v", err) + } + if want := auth.EmptyCredential; !reflect.DeepEqual(got, want) { + t.Errorf("Config.GetCredential(%s) = %v, want %v", server, got, want) + } +} + +func TestConfig_DeleteCredential_notExistRecord(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + + // prepare test content + server := "registry1.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + RefreshToken: "refresh_token", + AccessToken: "access_token", + } + testCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + IdentityToken: cred.RefreshToken, + RegistryToken: cred.AccessToken, + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(testCfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + // test get + got, err := cfg.GetCredential(server) + if err != nil { + t.Fatalf("Config.GetCredential() error = %v", err) + } + if want := cred; !reflect.DeepEqual(got, want) { + t.Errorf("Config.GetCredential(%s) = %v, want %v", server, got, want) + } + + // test delete + if err := cfg.DeleteCredential("test.example.com"); err != nil { + t.Fatalf("Config.DeleteCredential() error = %v", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotTestCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotTestCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantTestCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + server: testCfg.AuthConfigs[server], + }, + SomeConfigField: testCfg.SomeConfigField, + } + if !reflect.DeepEqual(gotTestCfg, wantTestCfg) { + t.Errorf("Decoded config = %v, want %v", gotTestCfg, wantTestCfg) + } + + // test get again + got, err = cfg.GetCredential(server) + if err != nil { + t.Fatalf("Config.GetCredential() error = %v", err) + } + if want := cred; !reflect.DeepEqual(got, want) { + t.Errorf("Config.GetCredential(%s) = %v, want %v", server, got, want) + } +} + +func TestConfig_DeleteCredential_notExistConfig(t *testing.T) { + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + + cfg, err := Load(configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + + server := "test.example.com" + // test delete + if err := cfg.DeleteCredential(server); err != nil { + t.Fatalf("Config.DeleteCredential() error = %v", err) + } + + // verify config file is not created + _, err = os.Stat(configPath) + if wantErr := os.ErrNotExist; !errors.Is(err, wantErr) { + t.Errorf("Stat(%s) error = %v, wantErr %v", configPath, err, wantErr) + } +} + +func TestConfig_GetCredentialHelper(t *testing.T) { + cfg, err := Load("../../testdata/credHelpers_config.json") + if err != nil { + t.Fatal("Load() error =", err) + } + + tests := []struct { + name string + serverAddress string + want string + }{ + { + name: "Get cred helper: registry_helper1", + serverAddress: "registry1.example.com", + want: "registry1-helper", + }, + { + name: "Get cred helper: registry_helper2", + serverAddress: "registry2.example.com", + want: "registry2-helper", + }, + { + name: "Empty cred helper configured", + serverAddress: "registry3.example.com", + want: "", + }, + { + name: "No cred helper configured", + serverAddress: "whatever.example.com", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := cfg.GetCredentialHelper(tt.serverAddress); got != tt.want { + t.Errorf("Config.GetCredentialHelper() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfig_CredentialsStore(t *testing.T) { + tests := []struct { + name string + configPath string + want string + }{ + { + name: "creds store configured", + configPath: "../../testdata/credsStore_config.json", + want: "teststore", + }, + { + name: "No creds store configured", + configPath: "../../testdata/credsHelpers_config.json", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg, err := Load(tt.configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + if got := cfg.CredentialsStore(); got != tt.want { + t.Errorf("Config.CredentialsStore() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfig_SetCredentialsStore(t *testing.T) { + // prepare test content + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + testCfg := configtest.Config{ + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(testCfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // test SetCredentialsStore + cfg, err := Load(configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + credsStore := "testStore" + if err := cfg.SetCredentialsStore(credsStore); err != nil { + t.Fatal("Config.SetCredentialsStore() error =", err) + } + + // verify + if got := cfg.credentialsStore; got != credsStore { + t.Errorf("Config.credentialsStore = %v, want %v", got, credsStore) + } + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + var gotTestCfg1 configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotTestCfg1); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + if err := configFile.Close(); err != nil { + t.Fatal("failed to close config file:", err) + } + + wantTestCfg1 := configtest.Config{ + AuthConfigs: make(map[string]configtest.AuthConfig), + CredentialsStore: credsStore, + SomeConfigField: testCfg.SomeConfigField, + } + if !reflect.DeepEqual(gotTestCfg1, wantTestCfg1) { + t.Errorf("Decoded config = %v, want %v", gotTestCfg1, wantTestCfg1) + } + + // test SetCredentialsStore: set as empty + if err := cfg.SetCredentialsStore(""); err != nil { + t.Fatal("Config.SetCredentialsStore() error =", err) + } + // verify + if got := cfg.credentialsStore; got != "" { + t.Errorf("Config.credentialsStore = %v, want empty", got) + } + // verify config file + configFile, err = os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + var gotTestCfg2 configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotTestCfg2); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + if err := configFile.Close(); err != nil { + t.Fatal("failed to close config file:", err) + } + + wantTestCfg2 := configtest.Config{ + AuthConfigs: make(map[string]configtest.AuthConfig), + SomeConfigField: testCfg.SomeConfigField, + } + if !reflect.DeepEqual(gotTestCfg2, wantTestCfg2) { + t.Errorf("Decoded config = %v, want %v", gotTestCfg2, wantTestCfg2) + } +} + +func TestConfig_IsAuthConfigured(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + fileName string + shouldCreateFile bool + cfg configtest.Config + want bool + }{ + { + name: "not existing file", + fileName: "config.json", + shouldCreateFile: false, + cfg: configtest.Config{}, + want: false, + }, + { + name: "no auth", + fileName: "config.json", + shouldCreateFile: true, + cfg: configtest.Config{ + SomeConfigField: 123, + }, + want: false, + }, + { + name: "empty auths exist", + fileName: "empty_auths.json", + shouldCreateFile: true, + cfg: configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{}, + }, + want: false, + }, + { + name: "auths exist, but no credential", + fileName: "no_cred_auths.json", + shouldCreateFile: true, + cfg: configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": {}, + }, + }, + want: true, + }, + { + name: "auths exist", + fileName: "auths.json", + shouldCreateFile: true, + cfg: configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + }, + }, + want: true, + }, + { + name: "credsStore exists", + fileName: "credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + CredentialsStore: "teststore", + }, + want: true, + }, + { + name: "empty credHelpers exist", + fileName: "empty_credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + CredentialHelpers: map[string]string{}, + }, + want: false, + }, + { + name: "credHelpers exist", + fileName: "credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + CredentialHelpers: map[string]string{ + "test.example.com": "testhelper", + }, + }, + want: true, + }, + { + name: "all exist", + fileName: "credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + SomeConfigField: 123, + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": {}, + }, + CredentialsStore: "teststore", + CredentialHelpers: map[string]string{ + "test.example.com": "testhelper", + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // prepare test content + configPath := filepath.Join(tempDir, tt.fileName) + if tt.shouldCreateFile { + jsonStr, err := json.Marshal(tt.cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + if got := cfg.IsAuthConfigured(); got != tt.want { + t.Errorf("IsAuthConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestConfig_saveFile(t *testing.T) { + tempDir := t.TempDir() + tests := []struct { + name string + fileName string + shouldCreateFile bool + oldCfg configtest.Config + newCfg configtest.Config + wantCfg configtest.Config + }{ + { + name: "set credsStore in a non-existing file", + fileName: "config.json", + oldCfg: configtest.Config{}, + newCfg: configtest.Config{ + CredentialsStore: "teststore", + }, + wantCfg: configtest.Config{ + AuthConfigs: make(map[string]configtest.AuthConfig), + CredentialsStore: "teststore", + }, + shouldCreateFile: false, + }, + { + name: "set credsStore in empty file", + fileName: "empty.json", + oldCfg: configtest.Config{}, + newCfg: configtest.Config{ + CredentialsStore: "teststore", + }, + wantCfg: configtest.Config{ + AuthConfigs: make(map[string]configtest.AuthConfig), + CredentialsStore: "teststore", + }, + shouldCreateFile: true, + }, + { + name: "set credsStore in a no-auth-configured file", + fileName: "empty.json", + oldCfg: configtest.Config{ + SomeConfigField: 123, + }, + newCfg: configtest.Config{ + CredentialsStore: "teststore", + }, + wantCfg: configtest.Config{ + SomeConfigField: 123, + AuthConfigs: make(map[string]configtest.AuthConfig), + CredentialsStore: "teststore", + }, + shouldCreateFile: true, + }, + { + name: "Set credsStore and credHelpers in an auth-configured file", + fileName: "auth_configured.json", + oldCfg: configtest.Config{ + SomeConfigField: 123, + AuthConfigs: map[string]configtest.AuthConfig{ + "registry1.example.com": { + SomeAuthField: "something", + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + }, + CredentialsStore: "oldstore", + CredentialHelpers: map[string]string{ + "registry2.example.com": "testhelper", + }, + }, + newCfg: configtest.Config{ + AuthConfigs: make(map[string]configtest.AuthConfig), + SomeConfigField: 123, + CredentialsStore: "newstore", + CredentialHelpers: map[string]string{ + "xxx": "yyy", + }, + }, + wantCfg: configtest.Config{ + SomeConfigField: 123, + AuthConfigs: map[string]configtest.AuthConfig{ + "registry1.example.com": { + SomeAuthField: "something", + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + }, + CredentialsStore: "newstore", + CredentialHelpers: map[string]string{ + "registry2.example.com": "testhelper", // cred helpers will not be updated + }, + }, + shouldCreateFile: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // prepare test content + configPath := filepath.Join(tempDir, tt.fileName) + if tt.shouldCreateFile { + jsonStr, err := json.Marshal(tt.oldCfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + } + + cfg, err := Load(configPath) + if err != nil { + t.Fatal("Load() error =", err) + } + cfg.credentialsStore = tt.newCfg.CredentialsStore + cfg.credentialHelpers = tt.newCfg.CredentialHelpers + if err := cfg.saveFile(); err != nil { + t.Fatal("saveFile() error =", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + if !reflect.DeepEqual(gotCfg, tt.wantCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, tt.wantCfg) + } + }) + } +} + +func Test_encodeAuth(t *testing.T) { + tests := []struct { + name string + username string + password string + want string + }{ + { + name: "Username and password", + username: "username", + password: "password", + want: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + { + name: "Username only", + username: "username", + password: "", + want: "dXNlcm5hbWU6", + }, + { + name: "Password only", + username: "", + password: "password", + want: "OnBhc3N3b3Jk", + }, + { + name: "Empty username and empty password", + username: "", + password: "", + want: "", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := encodeAuth(tt.username, tt.password); got != tt.want { + t.Errorf("encodeAuth() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_decodeAuth(t *testing.T) { + tests := []struct { + name string + authStr string + username string + password string + wantErr bool + }{ + { + name: "Valid base64", + authStr: "dXNlcm5hbWU6cGFzc3dvcmQ=", // username:password + username: "username", + password: "password", + }, + { + name: "Valid base64, username only", + authStr: "dXNlcm5hbWU6", // username: + username: "username", + }, + { + name: "Valid base64, password only", + authStr: "OnBhc3N3b3Jk", // :password + password: "password", + }, + { + name: "Valid base64, bad format", + authStr: "d2hhdGV2ZXI=", // whatever + username: "", + password: "", + wantErr: true, + }, + { + name: "Invalid base64", + authStr: "whatever", + username: "", + password: "", + wantErr: true, + }, + { + name: "Empty string", + authStr: "", + username: "", + password: "", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gotUsername, gotPassword, err := decodeAuth(tt.authStr) + if (err != nil) != tt.wantErr { + t.Errorf("decodeAuth() error = %v, wantErr %v", err, tt.wantErr) + return + } + if gotUsername != tt.username { + t.Errorf("decodeAuth() got = %v, want %v", gotUsername, tt.username) + } + if gotPassword != tt.password { + t.Errorf("decodeAuth() got1 = %v, want %v", gotPassword, tt.password) + } + }) + } +} diff --git a/registry/remote/credentials/internal/config/configtest/config.go b/registry/remote/credentials/internal/config/configtest/config.go new file mode 100644 index 00000000..5945e12e --- /dev/null +++ b/registry/remote/credentials/internal/config/configtest/config.go @@ -0,0 +1,39 @@ +/* +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 configtest + +// Config represents the structure of a config file for testing purpose. +type Config struct { + AuthConfigs map[string]AuthConfig `json:"auths"` + CredentialsStore string `json:"credsStore,omitempty"` + CredentialHelpers map[string]string `json:"credHelpers,omitempty"` + SomeConfigField int `json:"some_config_field"` +} + +// AuthConfig represents the structure of the "auths" field of a config file +// for testing purpose. +type AuthConfig struct { + SomeAuthField string `json:"some_auth_field,omitempty"` + Username string `json:"username,omitempty"` + Password string `json:"password,omitempty"` + Auth string `json:"auth,omitempty"` + + // IdentityToken is used to authenticate the user and get + // an access token for the registry. + IdentityToken string `json:"identitytoken,omitempty"` + // RegistryToken is a bearer token to be sent to a registry + RegistryToken string `json:"registrytoken,omitempty"` +} diff --git a/registry/remote/credentials/internal/executer/executer.go b/registry/remote/credentials/internal/executer/executer.go new file mode 100644 index 00000000..a074c684 --- /dev/null +++ b/registry/remote/credentials/internal/executer/executer.go @@ -0,0 +1,80 @@ +/* +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 executer is an abstraction for the docker credential helper protocol +// binaries. It is used by nativeStore to interact with installed binaries. +package executer + +import ( + "bytes" + "context" + "errors" + "io" + "os" + "os/exec" + + "oras.land/oras-go/v2/registry/remote/credentials/trace" +) + +// dockerDesktopHelperName is the name of the docker credentials helper +// execuatable. +const dockerDesktopHelperName = "docker-credential-desktop.exe" + +// Executer is an interface that simulates an executable binary. +type Executer interface { + Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) +} + +// executable implements the Executer interface. +type executable struct { + name string +} + +// New returns a new Executer instance. +func New(name string) Executer { + return &executable{ + name: name, + } +} + +// Execute operates on an executable binary and supports context. +func (c *executable) Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) { + cmd := exec.CommandContext(ctx, c.name, action) + cmd.Stdin = input + cmd.Stderr = os.Stderr + trace := trace.ContextExecutableTrace(ctx) + if trace != nil && trace.ExecuteStart != nil { + trace.ExecuteStart(c.name, action) + } + output, err := cmd.Output() + if trace != nil && trace.ExecuteDone != nil { + trace.ExecuteDone(c.name, action, err) + } + if err != nil { + switch execErr := err.(type) { + case *exec.ExitError: + if errMessage := string(bytes.TrimSpace(output)); errMessage != "" { + return nil, errors.New(errMessage) + } + case *exec.Error: + // check if the error is caused by Docker Desktop not running + if execErr.Err == exec.ErrNotFound && c.name == dockerDesktopHelperName { + return nil, errors.New("credentials store is configured to `desktop.exe` but Docker Desktop seems not running") + } + } + return nil, err + } + return output, nil +} diff --git a/registry/remote/credentials/internal/ioutil/ioutil.go b/registry/remote/credentials/internal/ioutil/ioutil.go new file mode 100644 index 00000000..b2e3179d --- /dev/null +++ b/registry/remote/credentials/internal/ioutil/ioutil.go @@ -0,0 +1,49 @@ +/* +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 ioutil + +import ( + "fmt" + "io" + "os" +) + +// Ingest writes content into a temporary ingest file with the file name format +// "oras_credstore_temp_{randomString}". +func Ingest(dir string, content io.Reader) (path string, ingestErr error) { + tempFile, err := os.CreateTemp(dir, "oras_credstore_temp_*") + if err != nil { + return "", fmt.Errorf("failed to create ingest file: %w", err) + } + path = tempFile.Name() + defer func() { + if err := tempFile.Close(); err != nil && ingestErr == nil { + ingestErr = fmt.Errorf("failed to close ingest file: %w", err) + } + // remove the temp file in case of error. + if ingestErr != nil { + os.Remove(path) + } + }() + + if err := tempFile.Chmod(0600); err != nil { + return "", fmt.Errorf("failed to ensure permission: %w", err) + } + if _, err := io.Copy(tempFile, content); err != nil { + return "", fmt.Errorf("failed to ingest: %w", err) + } + return +} diff --git a/registry/remote/credentials/native_store.go b/registry/remote/credentials/native_store.go new file mode 100644 index 00000000..9f4c7f74 --- /dev/null +++ b/registry/remote/credentials/native_store.go @@ -0,0 +1,139 @@ +/* +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" + "encoding/json" + "os/exec" + "strings" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/executer" +) + +const ( + remoteCredentialsPrefix = "docker-credential-" + emptyUsername = "" + errCredentialsNotFoundMessage = "credentials not found in native keychain" +) + +// dockerCredentials mimics how docker credential helper binaries store +// credential information. +// Reference: +// - https://docs.docker.com/engine/reference/commandline/login/#credential-helper-protocol +type dockerCredentials struct { + ServerURL string `json:"ServerURL"` + Username string `json:"Username"` + Secret string `json:"Secret"` +} + +// nativeStore implements a credentials store using native keychain to keep +// credentials secure. +type nativeStore struct { + exec executer.Executer +} + +// NewNativeStore creates a new native store that uses a remote helper program to +// manage credentials. +// +// The argument of NewNativeStore can be the native keychains +// ("wincred" for Windows, "pass" for linux and "osxkeychain" for macOS), +// or any program that follows the docker-credentials-helper protocol. +// +// Reference: +// - https://docs.docker.com/engine/reference/commandline/login#credentials-store +func NewNativeStore(helperSuffix string) Store { + return &nativeStore{ + exec: executer.New(remoteCredentialsPrefix + helperSuffix), + } +} + +// NewDefaultNativeStore returns a native store based on the platform-default +// docker credentials helper and a bool indicating if the native store is +// available. +// - Windows: "wincred" +// - Linux: "pass" or "secretservice" +// - macOS: "osxkeychain" +// +// Reference: +// - https://docs.docker.com/engine/reference/commandline/login/#credentials-store +func NewDefaultNativeStore() (Store, bool) { + if helper := getDefaultHelperSuffix(); helper != "" { + return NewNativeStore(helper), true + } + return nil, false +} + +// Get retrieves credentials from the store for the given server. +func (ns *nativeStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { + var cred auth.Credential + out, err := ns.exec.Execute(ctx, strings.NewReader(serverAddress), "get") + if err != nil { + if err.Error() == errCredentialsNotFoundMessage { + // do not return an error if the credentials are not in the keychain. + return auth.EmptyCredential, nil + } + return auth.EmptyCredential, err + } + var dockerCred dockerCredentials + if err := json.Unmarshal(out, &dockerCred); err != nil { + return auth.EmptyCredential, err + } + // bearer auth is used if the username is "" + if dockerCred.Username == emptyUsername { + cred.RefreshToken = dockerCred.Secret + } else { + cred.Username = dockerCred.Username + cred.Password = dockerCred.Secret + } + return cred, nil +} + +// Put saves credentials into the store. +func (ns *nativeStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { + dockerCred := &dockerCredentials{ + ServerURL: serverAddress, + Username: cred.Username, + Secret: cred.Password, + } + if cred.RefreshToken != "" { + dockerCred.Username = emptyUsername + dockerCred.Secret = cred.RefreshToken + } + credJSON, err := json.Marshal(dockerCred) + if err != nil { + return err + } + _, err = ns.exec.Execute(ctx, bytes.NewReader(credJSON), "store") + return err +} + +// Delete removes credentials from the store for the given server. +func (ns *nativeStore) Delete(ctx context.Context, serverAddress string) error { + _, err := ns.exec.Execute(ctx, strings.NewReader(serverAddress), "erase") + return err +} + +// getDefaultHelperSuffix returns the default credential helper suffix. +func getDefaultHelperSuffix() string { + platformDefault := getPlatformDefaultHelperSuffix() + if _, err := exec.LookPath(remoteCredentialsPrefix + platformDefault); err == nil { + return platformDefault + } + return "" +} diff --git a/registry/remote/credentials/native_store_darwin.go b/registry/remote/credentials/native_store_darwin.go new file mode 100644 index 00000000..1a9aca6f --- /dev/null +++ b/registry/remote/credentials/native_store_darwin.go @@ -0,0 +1,23 @@ +/* +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 + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + return "osxkeychain" +} diff --git a/registry/remote/credentials/native_store_generic.go b/registry/remote/credentials/native_store_generic.go new file mode 100644 index 00000000..5c7d4a3b --- /dev/null +++ b/registry/remote/credentials/native_store_generic.go @@ -0,0 +1,25 @@ +//go:build !windows && !darwin && !linux + +/* +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 + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + return "" +} diff --git a/registry/remote/credentials/native_store_linux.go b/registry/remote/credentials/native_store_linux.go new file mode 100644 index 00000000..f182923b --- /dev/null +++ b/registry/remote/credentials/native_store_linux.go @@ -0,0 +1,29 @@ +/* +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 "os/exec" + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + if _, err := exec.LookPath("pass"); err == nil { + return "pass" + } + + return "secretservice" +} diff --git a/registry/remote/credentials/native_store_test.go b/registry/remote/credentials/native_store_test.go new file mode 100644 index 00000000..df465ff8 --- /dev/null +++ b/registry/remote/credentials/native_store_test.go @@ -0,0 +1,385 @@ +/* +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" + "encoding/json" + "fmt" + "io" + "strings" + "testing" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/trace" +) + +const ( + basicAuthHost = "localhost:2333" + bearerAuthHost = "localhost:666" + exeErrorHost = "localhost:500/exeError" + jsonErrorHost = "localhost:500/jsonError" + noCredentialsHost = "localhost:404" + traceHost = "localhost:808" + testUsername = "test_username" + testPassword = "test_password" + testRefreshToken = "test_token" +) + +var ( + errCommandExited = fmt.Errorf("exited with error") + errExecute = fmt.Errorf("Execute failed") + errCredentialsNotFound = fmt.Errorf(errCredentialsNotFoundMessage) +) + +// testExecuter implements the Executer interface for testing purpose. +// It simulates interactions between the docker client and a remote +// credentials helper. +type testExecuter struct{} + +// Execute mocks the behavior of a credential helper binary. It returns responses +// and errors based on the input. +func (e *testExecuter) Execute(ctx context.Context, input io.Reader, action string) ([]byte, error) { + in, err := io.ReadAll(input) + if err != nil { + return nil, err + } + inS := string(in) + switch action { + case "get": + switch inS { + case basicAuthHost: + return []byte(`{"Username": "test_username", "Secret": "test_password"}`), nil + case bearerAuthHost: + return []byte(`{"Username": "", "Secret": "test_token"}`), nil + case exeErrorHost: + return []byte("Execute failed"), errExecute + case jsonErrorHost: + return []byte("json.Unmarshal failed"), nil + case noCredentialsHost: + return []byte("credentials not found"), errCredentialsNotFound + case traceHost: + traceHook := trace.ContextExecutableTrace(ctx) + if traceHook != nil { + if traceHook.ExecuteStart != nil { + traceHook.ExecuteStart("testExecuter", "get") + } + if traceHook.ExecuteDone != nil { + traceHook.ExecuteDone("testExecuter", "get", nil) + } + } + return []byte(`{"Username": "test_username", "Secret": "test_password"}`), nil + default: + return []byte("program failed"), errCommandExited + } + case "store": + var c dockerCredentials + err := json.NewDecoder(strings.NewReader(inS)).Decode(&c) + if err != nil { + return []byte("program failed"), errCommandExited + } + switch c.ServerURL { + case basicAuthHost, bearerAuthHost, exeErrorHost: + return nil, nil + case traceHost: + traceHook := trace.ContextExecutableTrace(ctx) + if traceHook != nil { + if traceHook.ExecuteStart != nil { + traceHook.ExecuteStart("testExecuter", "store") + } + if traceHook.ExecuteDone != nil { + traceHook.ExecuteDone("testExecuter", "store", nil) + } + } + return nil, nil + default: + return []byte("program failed"), errCommandExited + } + case "erase": + switch inS { + case basicAuthHost, bearerAuthHost: + return nil, nil + case traceHost: + traceHook := trace.ContextExecutableTrace(ctx) + if traceHook != nil { + if traceHook.ExecuteStart != nil { + traceHook.ExecuteStart("testExecuter", "erase") + } + if traceHook.ExecuteDone != nil { + traceHook.ExecuteDone("testExecuter", "erase", nil) + } + } + return nil, nil + default: + return []byte("program failed"), errCommandExited + } + } + return []byte(fmt.Sprintf("unknown argument %q with %q", action, inS)), errCommandExited +} + +func TestNativeStore_interface(t *testing.T) { + var ns interface{} = &nativeStore{} + if _, ok := ns.(Store); !ok { + t.Error("&NativeStore{} does not conform Store") + } +} + +func TestNativeStore_basicAuth(t *testing.T) { + ns := &nativeStore{ + &testExecuter{}, + } + // Put + err := ns.Put(context.Background(), basicAuthHost, auth.Credential{Username: testUsername, Password: testPassword}) + if err != nil { + t.Fatalf("basic auth test ns.Put fails: %v", err) + } + // Get + cred, err := ns.Get(context.Background(), basicAuthHost) + if err != nil { + t.Fatalf("basic auth test ns.Get fails: %v", err) + } + if cred.Username != testUsername { + t.Fatal("incorrect username") + } + if cred.Password != testPassword { + t.Fatal("incorrect password") + } + // Delete + err = ns.Delete(context.Background(), basicAuthHost) + if err != nil { + t.Fatalf("basic auth test ns.Delete fails: %v", err) + } +} + +func TestNativeStore_refreshToken(t *testing.T) { + ns := &nativeStore{ + &testExecuter{}, + } + // Put + err := ns.Put(context.Background(), bearerAuthHost, auth.Credential{RefreshToken: testRefreshToken}) + if err != nil { + t.Fatalf("refresh token test ns.Put fails: %v", err) + } + // Get + cred, err := ns.Get(context.Background(), bearerAuthHost) + if err != nil { + t.Fatalf("refresh token test ns.Get fails: %v", err) + } + if cred.Username != "" { + t.Fatalf("expect username to be empty, got %s", cred.Username) + } + if cred.RefreshToken != testRefreshToken { + t.Fatal("incorrect refresh token") + } + // Delete + err = ns.Delete(context.Background(), basicAuthHost) + if err != nil { + t.Fatalf("refresh token test ns.Delete fails: %v", err) + } +} + +func TestNativeStore_errorHandling(t *testing.T) { + ns := &nativeStore{ + &testExecuter{}, + } + // Get Error: Execute error + _, err := ns.Get(context.Background(), exeErrorHost) + if err != errExecute { + t.Fatalf("got error: %v, should get exeErr", err) + } + // Get Error: json.Unmarshal + _, err = ns.Get(context.Background(), jsonErrorHost) + if err == nil { + t.Fatalf("should get error from json.Unmarshal") + } + // Get: Should not return error when credentials are not found + _, err = ns.Get(context.Background(), noCredentialsHost) + if err != nil { + t.Fatalf("should not get error when no credentials are found") + } +} + +func TestNewDefaultNativeStore(t *testing.T) { + defaultHelper := getDefaultHelperSuffix() + wantOK := (defaultHelper != "") + + if _, ok := NewDefaultNativeStore(); ok != wantOK { + t.Errorf("NewDefaultNativeStore() = %v, want %v", ok, wantOK) + } +} + +func TestNativeStore_trace(t *testing.T) { + ns := &nativeStore{ + &testExecuter{}, + } + // create trace hooks that write to buffer + buffer := bytes.Buffer{} + traceHook := &trace.ExecutableTrace{ + ExecuteStart: func(executableName string, action string) { + buffer.WriteString(fmt.Sprintf("test trace, start the execution of executable %s with action %s ", executableName, action)) + }, + ExecuteDone: func(executableName string, action string, err error) { + buffer.WriteString(fmt.Sprintf("test trace, completed the execution of executable %s with action %s and got err %v", executableName, action, err)) + }, + } + ctx := trace.WithExecutableTrace(context.Background(), traceHook) + // Test ns.Put trace + err := ns.Put(ctx, traceHost, auth.Credential{Username: testUsername, Password: testPassword}) + if err != nil { + t.Fatalf("trace test ns.Put fails: %v", err) + } + bufferContent := buffer.String() + if bufferContent != "test trace, start the execution of executable testExecuter with action store test trace, completed the execution of executable testExecuter with action store and got err " { + t.Fatalf("incorrect buffer content: %s", bufferContent) + } + buffer.Reset() + // Test ns.Get trace + _, err = ns.Get(ctx, traceHost) + if err != nil { + t.Fatalf("trace test ns.Get fails: %v", err) + } + bufferContent = buffer.String() + if bufferContent != "test trace, start the execution of executable testExecuter with action get test trace, completed the execution of executable testExecuter with action get and got err " { + t.Fatalf("incorrect buffer content: %s", bufferContent) + } + buffer.Reset() + // Test ns.Delete trace + err = ns.Delete(ctx, traceHost) + if err != nil { + t.Fatalf("trace test ns.Delete fails: %v", err) + } + bufferContent = buffer.String() + if bufferContent != "test trace, start the execution of executable testExecuter with action erase test trace, completed the execution of executable testExecuter with action erase and got err " { + t.Fatalf("incorrect buffer content: %s", bufferContent) + } +} + +// This test ensures that a nil trace will not cause an error. +func TestNativeStore_noTrace(t *testing.T) { + ns := &nativeStore{ + &testExecuter{}, + } + // Put + err := ns.Put(context.Background(), traceHost, auth.Credential{Username: testUsername, Password: testPassword}) + if err != nil { + t.Fatalf("basic auth test ns.Put fails: %v", err) + } + // Get + cred, err := ns.Get(context.Background(), traceHost) + if err != nil { + t.Fatalf("basic auth test ns.Get fails: %v", err) + } + if cred.Username != testUsername { + t.Fatal("incorrect username") + } + if cred.Password != testPassword { + t.Fatal("incorrect password") + } + // Delete + err = ns.Delete(context.Background(), traceHost) + if err != nil { + t.Fatalf("basic auth test ns.Delete fails: %v", err) + } +} + +// This test ensures that an empty trace will not cause an error. +func TestNativeStore_emptyTrace(t *testing.T) { + ns := &nativeStore{ + &testExecuter{}, + } + traceHook := &trace.ExecutableTrace{} + ctx := trace.WithExecutableTrace(context.Background(), traceHook) + // Put + err := ns.Put(ctx, traceHost, auth.Credential{Username: testUsername, Password: testPassword}) + if err != nil { + t.Fatalf("basic auth test ns.Put fails: %v", err) + } + // Get + cred, err := ns.Get(ctx, traceHost) + if err != nil { + t.Fatalf("basic auth test ns.Get fails: %v", err) + } + if cred.Username != testUsername { + t.Fatal("incorrect username") + } + if cred.Password != testPassword { + t.Fatal("incorrect password") + } + // Delete + err = ns.Delete(ctx, traceHost) + if err != nil { + t.Fatalf("basic auth test ns.Delete fails: %v", err) + } +} + +func TestNativeStore_multipleTrace(t *testing.T) { + ns := &nativeStore{ + &testExecuter{}, + } + // create trace hooks that write to buffer + buffer := bytes.Buffer{} + trace1 := &trace.ExecutableTrace{ + ExecuteStart: func(executableName string, action string) { + buffer.WriteString(fmt.Sprintf("trace 1 start %s, %s ", executableName, action)) + }, + ExecuteDone: func(executableName string, action string, err error) { + buffer.WriteString(fmt.Sprintf("trace 1 done %s, %s, %v ", executableName, action, err)) + }, + } + ctx := context.Background() + ctx = trace.WithExecutableTrace(ctx, trace1) + trace2 := &trace.ExecutableTrace{ + ExecuteStart: func(executableName string, action string) { + buffer.WriteString(fmt.Sprintf("trace 2 start %s, %s ", executableName, action)) + }, + ExecuteDone: func(executableName string, action string, err error) { + buffer.WriteString(fmt.Sprintf("trace 2 done %s, %s, %v ", executableName, action, err)) + }, + } + ctx = trace.WithExecutableTrace(ctx, trace2) + trace3 := &trace.ExecutableTrace{} + ctx = trace.WithExecutableTrace(ctx, trace3) + // Test ns.Put trace + err := ns.Put(ctx, traceHost, auth.Credential{Username: testUsername, Password: testPassword}) + if err != nil { + t.Fatalf("trace test ns.Put fails: %v", err) + } + bufferContent := buffer.String() + if bufferContent != "trace 2 start testExecuter, store trace 1 start testExecuter, store trace 2 done testExecuter, store, trace 1 done testExecuter, store, " { + t.Fatalf("incorrect buffer content: %s", bufferContent) + } + buffer.Reset() + // Test ns.Get trace + _, err = ns.Get(ctx, traceHost) + if err != nil { + t.Fatalf("trace test ns.Get fails: %v", err) + } + bufferContent = buffer.String() + if bufferContent != "trace 2 start testExecuter, get trace 1 start testExecuter, get trace 2 done testExecuter, get, trace 1 done testExecuter, get, " { + t.Fatalf("incorrect buffer content: %s", bufferContent) + } + buffer.Reset() + // Test ns.Delete trace + err = ns.Delete(ctx, traceHost) + if err != nil { + t.Fatalf("trace test ns.Delete fails: %v", err) + } + bufferContent = buffer.String() + if bufferContent != "trace 2 start testExecuter, erase trace 1 start testExecuter, erase trace 2 done testExecuter, erase, trace 1 done testExecuter, erase, " { + t.Fatalf("incorrect buffer content: %s", bufferContent) + } +} diff --git a/registry/remote/credentials/native_store_windows.go b/registry/remote/credentials/native_store_windows.go new file mode 100644 index 00000000..e334cc79 --- /dev/null +++ b/registry/remote/credentials/native_store_windows.go @@ -0,0 +1,23 @@ +/* +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 + +// getPlatformDefaultHelperSuffix returns the platform default credential +// helper suffix. +// Reference: https://docs.docker.com/engine/reference/commandline/login/#default-behavior +func getPlatformDefaultHelperSuffix() string { + return "wincred" +} diff --git a/registry/remote/credentials/registry.go b/registry/remote/credentials/registry.go new file mode 100644 index 00000000..39735b77 --- /dev/null +++ b/registry/remote/credentials/registry.go @@ -0,0 +1,102 @@ +/* +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" + "fmt" + + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" +) + +// ErrClientTypeUnsupported is thrown by Login() when the registry's client type +// is not supported. +var ErrClientTypeUnsupported = errors.New("client type not supported") + +// Login provides the login functionality with the given credentials. The target +// registry's client should be nil or of type *auth.Client. Login uses +// a client local to the function and will not modify the original client of +// the registry. +func Login(ctx context.Context, store Store, reg *remote.Registry, cred auth.Credential) error { + // create a clone of the original registry for login purpose + regClone := *reg + // we use the original client if applicable, otherwise use a default client + var authClient auth.Client + if reg.Client == nil { + authClient = *auth.DefaultClient + authClient.Cache = nil // no cache + } else if client, ok := reg.Client.(*auth.Client); ok { + authClient = *client + } else { + return ErrClientTypeUnsupported + } + regClone.Client = &authClient + // update credentials with the client + authClient.Credential = auth.StaticCredential(reg.Reference.Registry, cred) + // validate and store the credential + if err := regClone.Ping(ctx); err != nil { + return fmt.Errorf("failed to validate the credentials for %s: %w", regClone.Reference.Registry, err) + } + hostname := ServerAddressFromRegistry(regClone.Reference.Registry) + if err := store.Put(ctx, hostname, cred); err != nil { + return fmt.Errorf("failed to store the credentials for %s: %w", hostname, err) + } + return nil +} + +// Logout provides the logout functionality given the registry name. +func Logout(ctx context.Context, store Store, registryName string) error { + registryName = ServerAddressFromRegistry(registryName) + if err := store.Delete(ctx, registryName); err != nil { + return fmt.Errorf("failed to delete the credential for %s: %w", registryName, err) + } + return nil +} + +// Credential returns a Credential() function that can be used by auth.Client. +func Credential(store Store) auth.CredentialFunc { + return func(ctx context.Context, hostport string) (auth.Credential, error) { + hostport = ServerAddressFromHostname(hostport) + if hostport == "" { + return auth.EmptyCredential, nil + } + return store.Get(ctx, hostport) + } +} + +// ServerAddressFromRegistry maps a registry to a server address, which is used as +// a key for credentials store. The Docker CLI expects that the credentials of +// the registry 'docker.io' will be added under the key "https://index.docker.io/v1/". +// See: https://github.com/moby/moby/blob/v24.0.2/registry/config.go#L25-L48 +func ServerAddressFromRegistry(registry string) string { + if registry == "docker.io" { + return "https://index.docker.io/v1/" + } + return registry +} + +// ServerAddressFromHostname maps a hostname to a server address, which is used as +// a key for credentials store. It is expected that the traffic targetting the +// host "registry-1.docker.io" will be redirected to "https://index.docker.io/v1/". +// See: https://github.com/moby/moby/blob/v24.0.2/registry/config.go#L25-L48 +func ServerAddressFromHostname(hostname string) string { + if hostname == "registry-1.docker.io" { + return "https://index.docker.io/v1/" + } + return hostname +} diff --git a/registry/remote/credentials/registry_test.go b/registry/remote/credentials/registry_test.go new file mode 100644 index 00000000..1b5dce31 --- /dev/null +++ b/registry/remote/credentials/registry_test.go @@ -0,0 +1,247 @@ +/* +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" + "encoding/base64" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "testing" + + "oras.land/oras-go/v2/registry/remote" + "oras.land/oras-go/v2/registry/remote/auth" +) + +// testStore implements the Store interface, used for testing purpose. +type testStore struct { + storage map[string]auth.Credential +} + +func (t *testStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { + return t.storage[serverAddress], nil +} + +func (t *testStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { + if len(t.storage) == 0 { + t.storage = make(map[string]auth.Credential) + } + t.storage[serverAddress] = cred + return nil +} + +func (t *testStore) Delete(ctx context.Context, serverAddress string) error { + delete(t.storage, serverAddress) + return nil +} + +func TestLogin(t *testing.T) { + // create a test registry + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wantedAuthHeader := "Basic " + base64.StdEncoding.EncodeToString([]byte(testUsername+":"+testPassword)) + authHeader := r.Header.Get("Authorization") + if authHeader != wantedAuthHeader { + w.Header().Set("Www-Authenticate", `Basic realm="Test Server"`) + w.WriteHeader(http.StatusUnauthorized) + } + })) + defer ts.Close() + uri, _ := url.Parse(ts.URL) + reg, err := remote.NewRegistry(uri.Host) + if err != nil { + t.Fatalf("cannot create test registry: %v", err) + } + reg.PlainHTTP = true + // create a test store + s := &testStore{} + tests := []struct { + name string + ctx context.Context + registry *remote.Registry + cred auth.Credential + wantErr bool + }{ + { + name: "login succeeds", + ctx: context.Background(), + cred: auth.Credential{Username: testUsername, Password: testPassword}, + wantErr: false, + }, + { + name: "login fails (incorrect password)", + ctx: context.Background(), + cred: auth.Credential{Username: testUsername, Password: "whatever"}, + wantErr: true, + }, + { + name: "login fails (nil context makes remote.Ping fails)", + ctx: nil, + cred: auth.Credential{Username: testUsername, Password: testPassword}, + wantErr: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // login to test registry + err := Login(tt.ctx, s, reg, tt.cred) + if (err != nil) != tt.wantErr { + t.Fatalf("Login() error = %v, wantErr %v", err, tt.wantErr) + } + if err != nil { + return + } + if got := s.storage[reg.Reference.Registry]; !reflect.DeepEqual(got, tt.cred) { + t.Fatalf("Stored credential = %v, want %v", got, tt.cred) + } + s.Delete(tt.ctx, reg.Reference.Registry) + }) + } +} + +func TestLogin_unsupportedClient(t *testing.T) { + var testClient http.Client + reg, err := remote.NewRegistry("whatever") + if err != nil { + t.Fatalf("cannot create test registry: %v", err) + } + reg.PlainHTTP = true + reg.Client = &testClient + ctx := context.Background() + + s := &testStore{} + cred := auth.EmptyCredential + err = Login(ctx, s, reg, cred) + if wantErr := ErrClientTypeUnsupported; !errors.Is(err, wantErr) { + t.Errorf("Login() error = %v, wantErr %v", err, wantErr) + } +} + +func TestLogout(t *testing.T) { + // create a test store + s := &testStore{} + s.storage = map[string]auth.Credential{ + "localhost:2333": {Username: "test_user", Password: "test_word"}, + "https://index.docker.io/v1/": {Username: "user", Password: "word"}, + } + tests := []struct { + name string + ctx context.Context + store Store + registryName string + wantErr bool + }{ + { + name: "logout of regular registry", + ctx: context.Background(), + registryName: "localhost:2333", + wantErr: false, + }, + { + name: "logout of docker.io", + ctx: context.Background(), + registryName: "docker.io", + wantErr: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if err := Logout(tt.ctx, s, tt.registryName); (err != nil) != tt.wantErr { + t.Fatalf("Logout() error = %v, wantErr %v", err, tt.wantErr) + } + if s.storage[tt.registryName] != auth.EmptyCredential { + t.Error("Credentials are not deleted") + } + }) + } +} + +func Test_mapHostname(t *testing.T) { + tests := []struct { + name string + host string + want string + }{ + { + name: "map docker.io to https://index.docker.io/v1/", + host: "docker.io", + want: "https://index.docker.io/v1/", + }, + { + name: "do not map other host names", + host: "localhost:2333", + want: "localhost:2333", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := ServerAddressFromRegistry(tt.host); got != tt.want { + t.Errorf("mapHostname() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCredential(t *testing.T) { + // create a test store + s := &testStore{} + s.storage = map[string]auth.Credential{ + "localhost:2333": {Username: "test_user", Password: "test_word"}, + "https://index.docker.io/v1/": {Username: "user", Password: "word"}, + } + // create a test client using Credential + testClient := &auth.Client{} + testClient.Credential = Credential(s) + tests := []struct { + name string + registry string + wantCredential auth.Credential + }{ + { + name: "get credentials for localhost:2333", + registry: "localhost:2333", + wantCredential: auth.Credential{Username: "test_user", Password: "test_word"}, + }, + { + name: "get credentials for registry-1.docker.io", + registry: "registry-1.docker.io", + wantCredential: auth.Credential{Username: "user", Password: "word"}, + }, + { + name: "get credentials for a registry not stored", + registry: "localhost:6666", + wantCredential: auth.EmptyCredential, + }, + { + name: "get credentials for an empty string", + registry: "", + wantCredential: auth.EmptyCredential, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := testClient.Credential(context.Background(), tt.registry) + if err != nil { + t.Errorf("could not get credential: %v", err) + } + if !reflect.DeepEqual(got, tt.wantCredential) { + t.Errorf("Credential() = %v, want %v", got, tt.wantCredential) + } + }) + } +} diff --git a/registry/remote/credentials/store.go b/registry/remote/credentials/store.go new file mode 100644 index 00000000..973e0e67 --- /dev/null +++ b/registry/remote/credentials/store.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 credentials supports reading, saving, and removing credentials from +// Docker configuration files and external credential stores that follow +// the Docker credential helper protocol. +// +// Reference: https://docs.docker.com/engine/reference/commandline/login/#credential-stores +package credentials + +import ( + "context" + "fmt" + "os" + "path/filepath" + "sync" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config" +) + +const ( + dockerConfigDirEnv = "DOCKER_CONFIG" + dockerConfigFileDir = ".docker" + dockerConfigFileName = "config.json" +) + +// Store is the interface that any credentials store must implement. +type Store interface { + // Get retrieves credentials from the store for the given server address. + Get(ctx context.Context, serverAddress string) (auth.Credential, error) + // 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. + Delete(ctx context.Context, serverAddress string) error +} + +// DynamicStore dynamically determines which store to use based on the settings +// in the config file. +type DynamicStore struct { + config *config.Config + options StoreOptions + detectedCredsStore string + setCredsStoreOnce sync.Once +} + +// StoreOptions provides options for NewStore. +type StoreOptions struct { + // AllowPlaintextPut allows saving credentials in plaintext in the config + // file. + // - If AllowPlaintextPut is set to false (default value), Put() will + // return an error when native store is not available. + // - If AllowPlaintextPut is set to true, Put() will save credentials in + // plaintext in the config file when native store is not available. + AllowPlaintextPut bool + + // DetectDefaultNativeStore enables detecting the platform-default native + // credentials store when the config file has no authentication information. + // + // If DetectDefaultNativeStore is set to true, the store will detect and set + // the default native credentials store in the "credsStore" field of the + // config file. + // - Windows: "wincred" + // - Linux: "pass" or "secretservice" + // - macOS: "osxkeychain" + // + // References: + // - https://docs.docker.com/engine/reference/commandline/login/#credentials-store + // - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties + DetectDefaultNativeStore bool +} + +// NewStore returns a Store based on the given configuration file. +// +// For Get(), Put() and Delete(), the returned Store will dynamically determine +// which underlying credentials store to use for the given server address. +// The underlying credentials store is determined in the following order: +// 1. Native server-specific credential helper +// 2. Native credentials store +// 3. The plain-text config file itself +// +// References: +// - https://docs.docker.com/engine/reference/commandline/login/#credentials-store +// - https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties +func NewStore(configPath string, opts StoreOptions) (*DynamicStore, error) { + cfg, err := config.Load(configPath) + if err != nil { + return nil, err + } + ds := &DynamicStore{ + config: cfg, + options: opts, + } + if opts.DetectDefaultNativeStore && !cfg.IsAuthConfigured() { + // no authentication configured, detect the default credentials store + ds.detectedCredsStore = getDefaultHelperSuffix() + } + return ds, nil +} + +// NewStoreFromDocker returns a Store based on the default docker config file. +// - If the $DOCKER_CONFIG environment variable is set, +// $DOCKER_CONFIG/config.json will be used. +// - Otherwise, the default location $HOME/.docker/config.json will be used. +// +// NewStoreFromDocker internally calls [NewStore]. +// +// References: +// - https://docs.docker.com/engine/reference/commandline/cli/#configuration-files +// - https://docs.docker.com/engine/reference/commandline/cli/#change-the-docker-directory +func NewStoreFromDocker(opt StoreOptions) (*DynamicStore, error) { + configPath, err := getDockerConfigPath() + if err != nil { + return nil, err + } + return NewStore(configPath, opt) +} + +// Get retrieves credentials from the store for the given server address. +func (ds *DynamicStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { + return ds.getStore(serverAddress).Get(ctx, serverAddress) +} + +// Put saves credentials into the store for the given server address. +// Put returns ErrPlaintextPutDisabled if native store is not available and +// [StoreOptions].AllowPlaintextPut is set to false. +func (ds *DynamicStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) (returnErr error) { + if err := ds.getStore(serverAddress).Put(ctx, serverAddress, cred); err != nil { + return err + } + // save the detected creds store back to the config file on first put + ds.setCredsStoreOnce.Do(func() { + if ds.detectedCredsStore != "" { + if err := ds.config.SetCredentialsStore(ds.detectedCredsStore); err != nil { + returnErr = fmt.Errorf("failed to set credsStore: %w", err) + } + } + }) + return returnErr +} + +// Delete removes credentials from the store for the given server address. +func (ds *DynamicStore) Delete(ctx context.Context, serverAddress string) error { + return ds.getStore(serverAddress).Delete(ctx, serverAddress) +} + +// IsAuthConfigured returns whether there is authentication configured in the +// config file or not. +// +// IsAuthConfigured returns true when: +// - The "credsStore" field is not empty +// - Or the "credHelpers" field is not empty +// - Or there is any entry in the "auths" field +func (ds *DynamicStore) IsAuthConfigured() bool { + return ds.config.IsAuthConfigured() +} + +// getHelperSuffix returns the credential helper suffix for the given server +// address. +func (ds *DynamicStore) getHelperSuffix(serverAddress string) string { + // 1. Look for a server-specific credential helper first + if helper := ds.config.GetCredentialHelper(serverAddress); helper != "" { + return helper + } + // 2. Then look for the configured native store + if credsStore := ds.config.CredentialsStore(); credsStore != "" { + return credsStore + } + // 3. Use the detected default store + return ds.detectedCredsStore +} + +// getStore returns a store for the given server address. +func (ds *DynamicStore) getStore(serverAddress string) Store { + if helper := ds.getHelperSuffix(serverAddress); helper != "" { + return NewNativeStore(helper) + } + + fs := newFileStore(ds.config) + fs.DisablePut = !ds.options.AllowPlaintextPut + return fs +} + +// getDockerConfigPath returns the path to the default docker config file. +func getDockerConfigPath() (string, error) { + // first try the environment variable + configDir := os.Getenv(dockerConfigDirEnv) + if configDir == "" { + // then try home directory + homeDir, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("failed to get user home directory: %w", err) + } + configDir = filepath.Join(homeDir, dockerConfigFileDir) + } + return filepath.Join(configDir, dockerConfigFileName), nil +} + +// storeWithFallbacks is a store that has multiple fallback stores. +type storeWithFallbacks struct { + stores []Store +} + +// NewStoreWithFallbacks returns a new store based on the given stores. +// - Get() searches the primary and the fallback stores +// for the credentials and returns when it finds the +// credentials in any of the stores. +// - Put() saves the credentials into the primary store. +// - Delete() deletes the credentials from the primary store. +func NewStoreWithFallbacks(primary Store, fallbacks ...Store) Store { + if len(fallbacks) == 0 { + return primary + } + return &storeWithFallbacks{ + stores: append([]Store{primary}, fallbacks...), + } +} + +// Get retrieves credentials from the StoreWithFallbacks for the given server. +// It searches the primary and the fallback stores for the credentials of serverAddress +// and returns when it finds the credentials in any of the stores. +func (sf *storeWithFallbacks) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { + for _, s := range sf.stores { + cred, err := s.Get(ctx, serverAddress) + if err != nil { + return auth.EmptyCredential, err + } + if cred != auth.EmptyCredential { + return cred, nil + } + } + return auth.EmptyCredential, nil +} + +// Put saves credentials into the StoreWithFallbacks. It puts +// the credentials into the primary store. +func (sf *storeWithFallbacks) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { + return sf.stores[0].Put(ctx, serverAddress, cred) +} + +// Delete removes credentials from the StoreWithFallbacks for the given server. +// It deletes the credentials from the primary store. +func (sf *storeWithFallbacks) Delete(ctx context.Context, serverAddress string) error { + return sf.stores[0].Delete(ctx, serverAddress) +} diff --git a/registry/remote/credentials/store_test.go b/registry/remote/credentials/store_test.go new file mode 100644 index 00000000..285e2796 --- /dev/null +++ b/registry/remote/credentials/store_test.go @@ -0,0 +1,982 @@ +/* +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" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials/internal/config/configtest" +) + +type badStore struct{} + +var errBadStore = errors.New("bad store!") + +// Get retrieves credentials from the store for the given server address. +func (s *badStore) Get(ctx context.Context, serverAddress string) (auth.Credential, error) { + return auth.EmptyCredential, errBadStore +} + +// Put saves credentials into the store for the given server address. +func (s *badStore) Put(ctx context.Context, serverAddress string, cred auth.Credential) error { + return errBadStore +} + +// Delete removes credentials from the store for the given server address. +func (s *badStore) Delete(ctx context.Context, serverAddress string) error { + return errBadStore +} + +func Test_DynamicStore_IsAuthConfigured(t *testing.T) { + tempDir := t.TempDir() + + tests := []struct { + name string + fileName string + shouldCreateFile bool + cfg configtest.Config + want bool + }{ + { + name: "not existing file", + fileName: "config.json", + shouldCreateFile: false, + cfg: configtest.Config{}, + want: false, + }, + { + name: "no auth", + fileName: "config.json", + shouldCreateFile: true, + cfg: configtest.Config{ + SomeConfigField: 123, + }, + want: false, + }, + { + name: "empty auths exist", + fileName: "empty_auths.json", + shouldCreateFile: true, + cfg: configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{}, + }, + want: false, + }, + { + name: "auths exist, but no credential", + fileName: "no_cred_auths.json", + shouldCreateFile: true, + cfg: configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": {}, + }, + }, + want: true, + }, + { + name: "auths exist", + fileName: "auths.json", + shouldCreateFile: true, + cfg: configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + }, + }, + want: true, + }, + { + name: "credsStore exists", + fileName: "credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + CredentialsStore: "teststore", + }, + want: true, + }, + { + name: "empty credHelpers exist", + fileName: "empty_credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + CredentialHelpers: map[string]string{}, + }, + want: false, + }, + { + name: "credHelpers exist", + fileName: "credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + CredentialHelpers: map[string]string{ + "test.example.com": "testhelper", + }, + }, + want: true, + }, + { + name: "all exist", + fileName: "credsStore.json", + shouldCreateFile: true, + cfg: configtest.Config{ + SomeConfigField: 123, + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": {}, + }, + CredentialsStore: "teststore", + CredentialHelpers: map[string]string{ + "test.example.com": "testhelper", + }, + }, + want: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // prepare test content + configPath := filepath.Join(tempDir, tt.fileName) + if tt.shouldCreateFile { + jsonStr, err := json.Marshal(tt.cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + } + + ds, err := NewStore(configPath, StoreOptions{}) + if err != nil { + t.Fatal("newStore() error =", err) + } + if got := ds.IsAuthConfigured(); got != tt.want { + t.Errorf("DynamicStore.IsAuthConfigured() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_DynamicStore_authConfigured(t *testing.T) { + // prepare test content + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "auth_configured.json") + config := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "xxx": {}, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + ds, err := NewStore(configPath, StoreOptions{AllowPlaintextPut: true}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + + // test IsAuthConfigured + authConfigured := ds.IsAuthConfigured() + if want := true; authConfigured != want { + t.Errorf("DynamicStore.IsAuthConfigured() = %v, want %v", authConfigured, want) + } + + serverAddr := "test.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + } + ctx := context.Background() + + // test put + if err := ds.Put(ctx, serverAddr, cred); err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + // Put() should not set detected store back to config + if got := ds.detectedCredsStore; got != "" { + t.Errorf("ds.detectedCredsStore = %v, want empty", got) + } + if got := ds.config.CredentialsStore(); got != "" { + t.Errorf("ds.config.CredentialsStore() = %v, want empty", got) + } + + // test get + got, err := ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := cred; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } + + // test delete + err = ds.Delete(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Delete() error =", err) + } + + // verify delete + got, err = ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := auth.EmptyCredential; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } +} + +func Test_DynamicStore_authConfigured_DetectDefaultNativeStore(t *testing.T) { + // prepare test content + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "auth_configured.json") + config := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "xxx": {}, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + opts := StoreOptions{ + AllowPlaintextPut: true, + DetectDefaultNativeStore: true, + } + ds, err := NewStore(configPath, opts) + if err != nil { + t.Fatal("NewStore() error =", err) + } + + // test IsAuthConfigured + authConfigured := ds.IsAuthConfigured() + if want := true; authConfigured != want { + t.Errorf("DynamicStore.IsAuthConfigured() = %v, want %v", authConfigured, want) + } + + serverAddr := "test.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + } + ctx := context.Background() + + // test put + if err := ds.Put(ctx, serverAddr, cred); err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + // Put() should not set detected store back to config + if got := ds.detectedCredsStore; got != "" { + t.Errorf("ds.detectedCredsStore = %v, want empty", got) + } + if got := ds.config.CredentialsStore(); got != "" { + t.Errorf("ds.config.CredentialsStore() = %v, want empty", got) + } + + // test get + got, err := ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := cred; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } + + // test delete + err = ds.Delete(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Delete() error =", err) + } + + // verify delete + got, err = ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := auth.EmptyCredential; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } +} + +func Test_DynamicStore_noAuthConfigured(t *testing.T) { + // prepare test content + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "no_auth_configured.json") + cfg := configtest.Config{ + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + ds, err := NewStore(configPath, StoreOptions{AllowPlaintextPut: true}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + + // test IsAuthConfigured + authConfigured := ds.IsAuthConfigured() + if want := false; authConfigured != want { + t.Errorf("DynamicStore.IsAuthConfigured() = %v, want %v", authConfigured, want) + } + + serverAddr := "test.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + } + ctx := context.Background() + + // Get() should not set detected store back to config + if _, err := ds.Get(ctx, serverAddr); err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + + // test put + if err := ds.Put(ctx, serverAddr, cred); err != nil { + t.Fatal("DynamicStore.Put() error =", err) + } + // Put() should not set detected store back to config + if got := ds.detectedCredsStore; got != "" { + t.Errorf("ds.detectedCredsStore = %v, want empty", got) + } + if got := ds.config.CredentialsStore(); got != "" { + t.Errorf("ds.config.CredentialsStore() = %v, want empty", got) + } + + // test get + got, err := ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := cred; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } + + // test delete + err = ds.Delete(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Delete() error =", err) + } + + // verify delete + got, err = ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := auth.EmptyCredential; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } +} + +func Test_DynamicStore_noAuthConfigured_DetectDefaultNativeStore(t *testing.T) { + // prepare test content + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "no_auth_configured.json") + cfg := configtest.Config{ + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + opts := StoreOptions{ + AllowPlaintextPut: true, + DetectDefaultNativeStore: true, + } + ds, err := NewStore(configPath, opts) + if err != nil { + t.Fatal("NewStore() error =", err) + } + + // test IsAuthConfigured + authConfigured := ds.IsAuthConfigured() + if want := false; authConfigured != want { + t.Errorf("DynamicStore.IsAuthConfigured() = %v, want %v", authConfigured, want) + } + + serverAddr := "test.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + } + ctx := context.Background() + + // Get() should set detectedCredsStore only, but should not save it back to config + if _, err := ds.Get(ctx, serverAddr); err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if defaultStore := getDefaultHelperSuffix(); defaultStore != "" { + if got := ds.detectedCredsStore; got != defaultStore { + t.Errorf("ds.detectedCredsStore = %v, want %v", got, defaultStore) + } + } + if got := ds.config.CredentialsStore(); got != "" { + t.Errorf("ds.config.CredentialsStore() = %v, want empty", got) + } + + // test put + if err := ds.Put(ctx, serverAddr, cred); err != nil { + t.Fatal("DynamicStore.Put() error =", err) + } + + // Put() should set the detected store back to config + if got := ds.config.CredentialsStore(); got != ds.detectedCredsStore { + t.Errorf("ds.config.CredentialsStore() = %v, want %v", got, ds.detectedCredsStore) + } + + // test get + got, err := ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := cred; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } + + // test delete + err = ds.Delete(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Delete() error =", err) + } + + // verify delete + got, err = ds.Get(ctx, serverAddr) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := auth.EmptyCredential; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } +} + +func Test_DynamicStore_fileStore_AllowPlainTextPut(t *testing.T) { + // prepare test content + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + serverAddr := "newtest.example.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + } + ctx := context.Background() + + cfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": {}, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + // test default option + ds, err := NewStore(configPath, StoreOptions{}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + err = ds.Put(ctx, serverAddr, cred) + if wantErr := ErrPlaintextPutDisabled; !errors.Is(err, wantErr) { + t.Errorf("DynamicStore.Put() error = %v, wantErr %v", err, wantErr) + } + + // test AllowPlainTextPut = true + ds, err = NewStore(configPath, StoreOptions{AllowPlaintextPut: true}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + if err := ds.Put(ctx, serverAddr, cred); err != nil { + t.Error("DynamicStore.Put() error =", err) + } + + // verify config file + configFile, err := os.Open(configPath) + if err != nil { + t.Fatalf("failed to open config file: %v", err) + } + defer configFile.Close() + var gotCfg configtest.Config + if err := json.NewDecoder(configFile).Decode(&gotCfg); err != nil { + t.Fatalf("failed to decode config file: %v", err) + } + wantCfg := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + "test.example.com": {}, + serverAddr: { + Auth: "dXNlcm5hbWU6cGFzc3dvcmQ=", + }, + }, + SomeConfigField: cfg.SomeConfigField, + } + if !reflect.DeepEqual(gotCfg, wantCfg) { + t.Errorf("Decoded config = %v, want %v", gotCfg, wantCfg) + } +} + +func Test_DynamicStore_getHelperSuffix(t *testing.T) { + tests := []struct { + name string + configPath string + serverAddress string + want string + }{ + { + name: "Get cred helper: registry_helper1", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry1.example.com", + want: "registry1-helper", + }, + { + name: "Get cred helper: registry_helper2", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry2.example.com", + want: "registry2-helper", + }, + { + name: "Empty cred helper configured", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry3.example.com", + want: "", + }, + { + name: "No cred helper and creds store configured", + configPath: "testdata/credHelpers_config.json", + serverAddress: "whatever.example.com", + want: "", + }, + { + name: "Choose cred helper over creds store", + configPath: "testdata/credsStore_config.json", + serverAddress: "test.example.com", + want: "test-helper", + }, + { + name: "No cred helper configured, choose cred store", + configPath: "testdata/credsStore_config.json", + serverAddress: "whatever.example.com", + want: "teststore", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ds, err := NewStore(tt.configPath, StoreOptions{}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + if got := ds.getHelperSuffix(tt.serverAddress); got != tt.want { + t.Errorf("DynamicStore.getHelperSuffix() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_DynamicStore_getStore_nativeStore(t *testing.T) { + tests := []struct { + name string + configPath string + serverAddress string + }{ + { + name: "Cred helper configured for registry1.example.com", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry1.example.com", + }, + { + name: "Cred helper configured for registry2.example.com", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry2.example.com", + }, + { + name: "Cred helper configured for test.example.com", + configPath: "testdata/credsStore_config.json", + serverAddress: "test.example.com", + }, + { + name: "No cred helper configured, use creds store", + configPath: "testdata/credsStore_config.json", + serverAddress: "whaterver.example.com", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ds, err := NewStore(tt.configPath, StoreOptions{}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + gotStore := ds.getStore(tt.serverAddress) + if _, ok := gotStore.(*nativeStore); !ok { + t.Errorf("gotStore is not a native store") + } + }) + } +} + +func Test_DynamicStore_getStore_fileStore(t *testing.T) { + tests := []struct { + name string + configPath string + serverAddress string + }{ + { + name: "Empty cred helper configured for registry3.example.com", + configPath: "testdata/credHelpers_config.json", + serverAddress: "registry3.example.com", + }, + { + name: "No cred helper configured", + configPath: "testdata/credHelpers_config.json", + serverAddress: "whatever.example.com", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ds, err := NewStore(tt.configPath, StoreOptions{}) + if err != nil { + t.Fatal("NewStore() error =", err) + } + gotStore := ds.getStore(tt.serverAddress) + gotFS1, ok := gotStore.(*FileStore) + if !ok { + t.Errorf("gotStore is not a file store") + } + + // get again, the two file stores should be based on the same config instance + gotStore = ds.getStore(tt.serverAddress) + gotFS2, ok := gotStore.(*FileStore) + if !ok { + t.Errorf("gotStore is not a file store") + } + if gotFS1.config != gotFS2.config { + t.Errorf("gotFS1 and gotFS2 are not based on the same config") + } + }) + } +} + +func Test_storeWithFallbacks_Get(t *testing.T) { + // prepare test content + server1 := "foo.registry.com" + cred1 := auth.Credential{ + Username: "username", + Password: "password", + } + server2 := "bar.registry.com" + cred2 := auth.Credential{ + RefreshToken: "identity_token", + } + + primaryStore := &testStore{} + fallbackStore1 := &testStore{ + storage: map[string]auth.Credential{ + server1: cred1, + }, + } + fallbackStore2 := &testStore{ + storage: map[string]auth.Credential{ + server2: cred2, + }, + } + sf := NewStoreWithFallbacks(primaryStore, fallbackStore1, fallbackStore2) + ctx := context.Background() + + // test Get() + got1, err := sf.Get(ctx, server1) + if err != nil { + t.Fatalf("storeWithFallbacks.Get(%s) error = %v", server1, err) + } + if want := cred1; got1 != cred1 { + t.Errorf("storeWithFallbacks.Get(%s) = %v, want %v", server1, got1, want) + } + got2, err := sf.Get(ctx, server2) + if err != nil { + t.Fatalf("storeWithFallbacks.Get(%s) error = %v", server2, err) + } + if want := cred2; got2 != cred2 { + t.Errorf("storeWithFallbacks.Get(%s) = %v, want %v", server2, got2, want) + } + + // test Get(): no credential found + got, err := sf.Get(ctx, "whaterver") + if err != nil { + t.Fatal("storeWithFallbacks.Get() error =", err) + } + if want := auth.EmptyCredential; got != want { + t.Errorf("storeWithFallbacks.Get() = %v, want %v", got, want) + } +} + +func Test_storeWithFallbacks_Get_throwError(t *testing.T) { + badStore := &badStore{} + goodStore := &testStore{} + sf := NewStoreWithFallbacks(badStore, goodStore) + ctx := context.Background() + + // test Get(): should throw error + _, err := sf.Get(ctx, "whatever") + if wantErr := errBadStore; !errors.Is(err, wantErr) { + t.Errorf("storeWithFallback.Get() error = %v, wantErr %v", err, wantErr) + } +} + +func Test_storeWithFallbacks_Put(t *testing.T) { + // prepare test content + cfg := configtest.Config{ + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(cfg) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "no_auth_configured.json") + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + opts := StoreOptions{ + AllowPlaintextPut: true, + } + primaryStore, err := NewStore(configPath, opts) // plaintext enabled + if err != nil { + t.Fatalf("NewStore(%s) error = %v", configPath, err) + } + badStore := &badStore{} // bad store + sf := NewStoreWithFallbacks(primaryStore, badStore) + ctx := context.Background() + + server := "example.registry.com" + cred := auth.Credential{ + Username: "username", + Password: "password", + } + // test Put() + if err := sf.Put(ctx, server, cred); err != nil { + t.Fatal("storeWithFallbacks.Put() error =", err) + } + // verify Get() + got, err := sf.Get(ctx, server) + if err != nil { + t.Fatal("storeWithFallbacks.Get() error =", err) + } + if want := cred; got != want { + t.Errorf("storeWithFallbacks.Get() = %v, want %v", got, want) + } +} + +func Test_storeWithFallbacks_Put_throwError(t *testing.T) { + badStore := &badStore{} + goodStore := &testStore{} + sf := NewStoreWithFallbacks(badStore, goodStore) + ctx := context.Background() + + // test Put(): should thrown error + err := sf.Put(ctx, "whatever", auth.Credential{}) + if wantErr := errBadStore; !errors.Is(err, wantErr) { + t.Errorf("storeWithFallback.Put() error = %v, wantErr %v", err, wantErr) + } +} + +func Test_storeWithFallbacks_Delete(t *testing.T) { + // prepare test content + server1 := "foo.registry.com" + cred1 := auth.Credential{ + Username: "username", + Password: "password", + } + server2 := "bar.registry.com" + cred2 := auth.Credential{ + RefreshToken: "identity_token", + } + + primaryStore := &testStore{ + storage: map[string]auth.Credential{ + server1: cred1, + server2: cred2, + }, + } + badStore := &badStore{} + sf := NewStoreWithFallbacks(primaryStore, badStore) + ctx := context.Background() + + // test Delete(): server1 + if err := sf.Delete(ctx, server1); err != nil { + t.Fatal("storeWithFallback.Delete()") + } + // verify primary store + if want := map[string]auth.Credential{server2: cred2}; !reflect.DeepEqual(primaryStore.storage, want) { + t.Errorf("primaryStore.storage = %v, want %v", primaryStore.storage, want) + } + + // test Delete(): server2 + if err := sf.Delete(ctx, server2); err != nil { + t.Fatal("storeWithFallback.Delete()") + } + // verify primary store + if want := map[string]auth.Credential{}; !reflect.DeepEqual(primaryStore.storage, want) { + t.Errorf("primaryStore.storage = %v, want %v", primaryStore.storage, want) + } +} + +func Test_storeWithFallbacks_Delete_throwError(t *testing.T) { + badStore := &badStore{} + goodStore := &testStore{} + sf := NewStoreWithFallbacks(badStore, goodStore) + ctx := context.Background() + + // test Delete(): should throw error + err := sf.Delete(ctx, "whatever") + if wantErr := errBadStore; !errors.Is(err, wantErr) { + t.Errorf("storeWithFallback.Delete() error = %v, wantErr %v", err, wantErr) + } +} + +func Test_getDockerConfigPath_env(t *testing.T) { + dir, err := os.Getwd() + if err != nil { + t.Fatal("os.Getwd() error =", err) + } + t.Setenv("DOCKER_CONFIG", dir) + + got, err := getDockerConfigPath() + if err != nil { + t.Fatal("getDockerConfigPath() error =", err) + } + if want := filepath.Join(dir, "config.json"); got != want { + t.Errorf("getDockerConfigPath() = %v, want %v", got, want) + } +} + +func Test_getDockerConfigPath_homeDir(t *testing.T) { + t.Setenv("DOCKER_CONFIG", "") + + got, err := getDockerConfigPath() + if err != nil { + t.Fatal("getDockerConfigPath() error =", err) + } + homeDir, err := os.UserHomeDir() + if err != nil { + t.Fatal("os.UserHomeDir()") + } + if want := filepath.Join(homeDir, ".docker", "config.json"); got != want { + t.Errorf("getDockerConfigPath() = %v, want %v", got, want) + } +} + +func TestNewStoreFromDocker(t *testing.T) { + // prepare test content + tempDir := t.TempDir() + configPath := filepath.Join(tempDir, "config.json") + t.Setenv("DOCKER_CONFIG", tempDir) + + serverAddr1 := "test.example.com" + cred1 := auth.Credential{ + Username: "foo", + Password: "bar", + } + config := configtest.Config{ + AuthConfigs: map[string]configtest.AuthConfig{ + serverAddr1: { + Auth: "Zm9vOmJhcg==", + }, + }, + SomeConfigField: 123, + } + jsonStr, err := json.Marshal(config) + if err != nil { + t.Fatalf("failed to marshal config: %v", err) + } + if err := os.WriteFile(configPath, jsonStr, 0666); err != nil { + t.Fatalf("failed to write config file: %v", err) + } + + ctx := context.Background() + + ds, err := NewStoreFromDocker(StoreOptions{AllowPlaintextPut: true}) + if err != nil { + t.Fatal("NewStoreFromDocker() error =", err) + } + + // test getting an existing credential + got, err := ds.Get(ctx, serverAddr1) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := cred1; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } + + // test putting a new credential + serverAddr2 := "newtest.example.com" + cred2 := auth.Credential{ + Username: "username", + Password: "password", + } + if err := ds.Put(ctx, serverAddr2, cred2); err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + + // test getting the new credential + got, err = ds.Get(ctx, serverAddr2) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := cred2; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } + + // test deleting the old credential + err = ds.Delete(ctx, serverAddr1) + if err != nil { + t.Fatal("DynamicStore.Delete() error =", err) + } + + // verify delete + got, err = ds.Get(ctx, serverAddr1) + if err != nil { + t.Fatal("DynamicStore.Get() error =", err) + } + if want := auth.EmptyCredential; got != want { + t.Errorf("DynamicStore.Get() = %v, want %v", got, want) + } +} diff --git a/registry/remote/credentials/testdata/bad_config b/registry/remote/credentials/testdata/bad_config new file mode 100644 index 00000000..22a4f5d5 --- /dev/null +++ b/registry/remote/credentials/testdata/bad_config @@ -0,0 +1 @@ +bad diff --git a/registry/remote/credentials/testdata/credHelpers_config.json b/registry/remote/credentials/testdata/credHelpers_config.json new file mode 100644 index 00000000..f33a98e2 --- /dev/null +++ b/registry/remote/credentials/testdata/credHelpers_config.json @@ -0,0 +1,15 @@ +{ + "auths": { + "registry1.example.com": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=" + }, + "registry3.example.com": { + "auth": "Zm9vOmJhcg==" + } + }, + "credHelpers": { + "registry1.example.com": "registry1-helper", + "registry2.example.com": "registry2-helper", + "registry3.example.com": "" + } +} diff --git a/registry/remote/credentials/testdata/credsStore_config.json b/registry/remote/credentials/testdata/credsStore_config.json new file mode 100644 index 00000000..40eb3843 --- /dev/null +++ b/registry/remote/credentials/testdata/credsStore_config.json @@ -0,0 +1,6 @@ +{ + "credHelpers": { + "test.example.com": "test-helper" + }, + "credsStore": "teststore" +} diff --git a/registry/remote/credentials/testdata/empty.json b/registry/remote/credentials/testdata/empty.json new file mode 100644 index 00000000..e69de29b diff --git a/registry/remote/credentials/testdata/invalid_auths_config.json b/registry/remote/credentials/testdata/invalid_auths_config.json new file mode 100644 index 00000000..8010010a --- /dev/null +++ b/registry/remote/credentials/testdata/invalid_auths_config.json @@ -0,0 +1,3 @@ +{ + "auths": "whaterver" +} diff --git a/registry/remote/credentials/testdata/invalid_auths_entry_config.json b/registry/remote/credentials/testdata/invalid_auths_entry_config.json new file mode 100644 index 00000000..60a45445 --- /dev/null +++ b/registry/remote/credentials/testdata/invalid_auths_entry_config.json @@ -0,0 +1,11 @@ +{ + "auths": { + "registry1.example.com": { + "auth": "username:password" + }, + "registry2.example.com": "whatever", + "registry3.example.com": { + "identitytoken": 123 + } + } +} diff --git a/registry/remote/credentials/testdata/no_auths_config.json b/registry/remote/credentials/testdata/no_auths_config.json new file mode 100644 index 00000000..e07eb621 --- /dev/null +++ b/registry/remote/credentials/testdata/no_auths_config.json @@ -0,0 +1,3 @@ +{ + "key": "val" +} diff --git a/registry/remote/credentials/testdata/valid_auths_config.json b/registry/remote/credentials/testdata/valid_auths_config.json new file mode 100644 index 00000000..e643c082 --- /dev/null +++ b/registry/remote/credentials/testdata/valid_auths_config.json @@ -0,0 +1,28 @@ +{ + "auths": { + "registry1.example.com": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=" + }, + "registry2.example.com": { + "identitytoken": "identity_token" + }, + "registry3.example.com": { + "registrytoken": "registry_token" + }, + "registry4.example.com": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", + "identitytoken": "identity_token", + "registrytoken": "registry_token" + }, + "registry5.example.com": {}, + "registry6.example.com": { + "username": "username", + "password": "password" + }, + "registry7.example.com": { + "auth": "dXNlcm5hbWU6cGFzc3dvcmQ=", + "username": "foo", + "password": "bar" + } + } +} diff --git a/registry/remote/credentials/trace/example_test.go b/registry/remote/credentials/trace/example_test.go new file mode 100644 index 00000000..65f6af78 --- /dev/null +++ b/registry/remote/credentials/trace/example_test.go @@ -0,0 +1,65 @@ +/* +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 trace_test + +import ( + "context" + "fmt" + + "oras.land/oras-go/v2/registry/remote/auth" + "oras.land/oras-go/v2/registry/remote/credentials" + "oras.land/oras-go/v2/registry/remote/credentials/trace" +) + +// An example on how to use ExecutableTrace with Stores. +func Example() { + // ExecutableTrace works with all Stores that may invoke executables, for + // example the Store returned from NewStore and NewNativeStore. + store, err := credentials.NewStore("example/path/config.json", credentials.StoreOptions{}) + if err != nil { + panic(err) + } + + // Define ExecutableTrace and add it to the context. The 'action' argument + // refers to one of 'store', 'get' and 'erase' defined by the docker + // credential helper protocol. + // Reference: https://docs.docker.com/engine/reference/commandline/login/#credential-helper-protocol + traceHooks := &trace.ExecutableTrace{ + ExecuteStart: func(executableName string, action string) { + fmt.Printf("executable %s, action %s started", executableName, action) + }, + ExecuteDone: func(executableName string, action string, err error) { + fmt.Printf("executable %s, action %s finished", executableName, action) + }, + } + ctx := trace.WithExecutableTrace(context.Background(), traceHooks) + + // Get, Put and Delete credentials from store. If any credential helper + // executable is run, traceHooks is executed. + err = store.Put(ctx, "localhost:5000", auth.Credential{Username: "testUsername", Password: "testPassword"}) + if err != nil { + panic(err) + } + + cred, err := store.Get(ctx, "localhost:5000") + if err != nil { + panic(err) + } + fmt.Println(cred) + + err = store.Delete(ctx, "localhost:5000") + if err != nil { + panic(err) + } +} diff --git a/registry/remote/credentials/trace/trace.go b/registry/remote/credentials/trace/trace.go new file mode 100644 index 00000000..b7cd8683 --- /dev/null +++ b/registry/remote/credentials/trace/trace.go @@ -0,0 +1,94 @@ +/* +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 trace + +import "context" + +// executableTraceContextKey is a value key used to retrieve the ExecutableTrace +// from Context. +type executableTraceContextKey struct{} + +// ExecutableTrace is a set of hooks used to trace the execution of binary +// executables. Any particular hook may be nil. +type ExecutableTrace struct { + // ExecuteStart is called before the execution of the executable. The + // executableName parameter is the name of the credential helper executable + // used with NativeStore. The action parameter is one of "store", "get" and + // "erase". + // + // Reference: + // - https://docs.docker.com/engine/reference/commandline/login#credentials-store + ExecuteStart func(executableName string, action string) + + // ExecuteDone is called after the execution of an executable completes. + // The executableName parameter is the name of the credential helper + // executable used with NativeStore. The action parameter is one of "store", + // "get" and "erase". The err parameter is the error (if any) returned from + // the execution. + // + // Reference: + // - https://docs.docker.com/engine/reference/commandline/login#credentials-store + ExecuteDone func(executableName string, action string, err error) +} + +// ContextExecutableTrace returns the ExecutableTrace associated with the +// context. If none, it returns nil. +func ContextExecutableTrace(ctx context.Context) *ExecutableTrace { + trace, _ := ctx.Value(executableTraceContextKey{}).(*ExecutableTrace) + return trace +} + +// WithExecutableTrace takes a Context and an ExecutableTrace, and returns a +// Context with the ExecutableTrace added as a Value. If the Context has a +// previously added trace, the hooks defined in the new trace will be added +// in addition to the previous ones. The recent hooks will be called first. +func WithExecutableTrace(ctx context.Context, trace *ExecutableTrace) context.Context { + if trace == nil { + return ctx + } + if oldTrace := ContextExecutableTrace(ctx); oldTrace != nil { + trace.compose(oldTrace) + } + return context.WithValue(ctx, executableTraceContextKey{}, trace) +} + +// compose takes an oldTrace and modifies the existing trace to include +// the hooks defined in the oldTrace. The hooks in the existing trace will +// be called first. +func (trace *ExecutableTrace) compose(oldTrace *ExecutableTrace) { + if oldStart := oldTrace.ExecuteStart; oldStart != nil { + start := trace.ExecuteStart + if start != nil { + trace.ExecuteStart = func(executableName, action string) { + start(executableName, action) + oldStart(executableName, action) + } + } else { + trace.ExecuteStart = oldStart + } + } + if oldDone := oldTrace.ExecuteDone; oldDone != nil { + done := trace.ExecuteDone + if done != nil { + trace.ExecuteDone = func(executableName, action string, err error) { + done(executableName, action, err) + oldDone(executableName, action, err) + } + } else { + trace.ExecuteDone = oldDone + } + } +}