Skip to content

Commit

Permalink
feat: introduce ReadOnlyFileStore
Browse files Browse the repository at this point in the history
Signed-off-by: Jakub Warczarek <[email protected]>
  • Loading branch information
programmer04 committed Dec 20, 2024
1 parent e4dc64e commit 0ad13e6
Show file tree
Hide file tree
Showing 5 changed files with 249 additions and 26 deletions.
88 changes: 84 additions & 4 deletions registry/remote/credentials/file_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ limitations under the License.
package credentials

import (
"bytes"
"context"
"encoding/json"
"errors"
Expand All @@ -25,6 +26,7 @@ import (
"testing"

"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/credentials/internal/config"
"oras.land/oras-go/v2/registry/remote/credentials/internal/config/configtest"
)

Expand Down Expand Up @@ -81,23 +83,46 @@ func TestNewFileStore_badFormat(t *testing.T) {
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Run(tt.name+" FileStore", func(t *testing.T) {
_, err := NewFileStore(tt.configPath)
if (err != nil) != tt.wantErr {
t.Errorf("NewFileStore() error = %v, wantErr %v", err, tt.wantErr)
return
}
})
t.Run(tt.name+" ReadOnlyFileStore", func(t *testing.T) {
f, err := os.Open(tt.configPath)
if err != nil {
t.Fatalf("failed to open file: %v", err)
}
defer f.Close()

_, err = NewReadOnlyFileStore(f)
if (err != nil) != tt.wantErr {
t.Errorf("NewReadOnlyFileStore() error = %v, wantErr %v", err, tt.wantErr)
return
}
})
}
}

func TestFileStore_Get_validConfig(t *testing.T) {
func TestFileStoreAndReadOnlyFileStore_Get_validConfig(t *testing.T) {
ctx := context.Background()
fs, err := NewFileStore("testdata/valid_auths_config.json")
const validAuthsConfigPath = "testdata/valid_auths_config.json"
fs, err := NewFileStore(validAuthsConfigPath)
if err != nil {
t.Fatal("NewFileStore() error =", err)
}

f, err := os.ReadFile(validAuthsConfigPath)
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
rofs, err := NewReadOnlyFileStore(bytes.NewReader(f))
if err != nil {
t.Fatalf("NewReadOnlyFileStore() error = %v", err)
}

tests := []struct {
name string
serverAddress string
Expand Down Expand Up @@ -169,7 +194,7 @@ func TestFileStore_Get_validConfig(t *testing.T) {
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Run(tt.name+" FileStore.Get()", func(t *testing.T) {
got, err := fs.Get(ctx, tt.serverAddress)
if (err != nil) != tt.wantErr {
t.Errorf("FileStore.Get() error = %v, wantErr %v", err, tt.wantErr)
Expand All @@ -179,6 +204,27 @@ func TestFileStore_Get_validConfig(t *testing.T) {
t.Errorf("FileStore.Get() = %v, want %v", got, tt.want)
}
})
t.Run(tt.name+" ReadOnlyFileStore.Get()", func(t *testing.T) {
got, err := rofs.Get(ctx, tt.serverAddress)
if (err != nil) != tt.wantErr {
t.Errorf("ReadOnlyFileStore.Get() error = %v, wantErr %v", err, tt.wantErr)
return
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("ReadOnlyFileStore.Get() = %v, want %v", got, tt.want)
}
})
}
}

func TestReadOnlyFileStore_Create_fromInvalidConfig(t *testing.T) {
f, err := os.ReadFile("testdata/invalid_auths_entry_config.json")
if err != nil {
t.Fatalf("failed to read file: %v", err)
}
_, err = NewReadOnlyFileStore(bytes.NewReader(f))
if !errors.Is(err, config.ErrInvalidConfigFormat) {
t.Fatalf("Error: %s is expected", config.ErrInvalidConfigFormat)
}
}

Expand Down Expand Up @@ -296,6 +342,23 @@ func TestFileStore_Get_notExistConfig(t *testing.T) {
}
}

func TestReadOnlyFileStore_Put_expectError(t *testing.T) {
const validAuthsConfigPath = "testdata/valid_auths_config.json"
f, err := os.ReadFile(validAuthsConfigPath)
if err != nil {
t.Fatalf("failed to read file: %v", err)
}

rofs, err := NewReadOnlyFileStore(bytes.NewReader(f))
if err != nil {
t.Fatalf("NewReadOnlyFileStore() error = %v", err)
}
err = rofs.Put(context.Background(), "registry.example.com", auth.Credential{})
if !errors.Is(err, ErrReadOnlyStore) {
t.Fatalf("Error: %s is expected", ErrReadOnlyStore)
}
}

func TestFileStore_Put_notExistConfig(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.json")
Expand Down Expand Up @@ -592,6 +655,23 @@ func TestFileStore_Put_passwordContainsColon(t *testing.T) {
}
}

func TestReadOnlyFileStore_Delete_expectError(t *testing.T) {
const validAuthsConfigPath = "testdata/valid_auths_config.json"
f, err := os.ReadFile(validAuthsConfigPath)
if err != nil {
t.Fatalf("failed to read file: %v", err)
}

rofs, err := NewReadOnlyFileStore(bytes.NewReader(f))
if err != nil {
t.Fatalf("NewReadOnlyFileStore() error = %v", err)
}
err = rofs.Delete(context.Background(), "registry.example.com")
if !errors.Is(err, ErrReadOnlyStore) {
t.Fatalf("Error: %s is expected", ErrReadOnlyStore)
}
}

func TestFileStore_Delete(t *testing.T) {
tempDir := t.TempDir()
configPath := filepath.Join(tempDir, "config.json")
Expand Down
23 changes: 1 addition & 22 deletions registry/remote/credentials/internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -160,28 +160,7 @@ func (cfg *Config) GetCredential(serverAddress string) (auth.Credential, error)
cfg.rwLock.RLock()
defer cfg.rwLock.RUnlock()

authCfgBytes, ok := cfg.authsCache[serverAddress]
if !ok {
// NOTE: the auth key for the server address may have been stored with
// a http/https prefix in legacy config files, e.g. "registry.example.com"
// can be stored as "https://registry.example.com/".
var matched bool
for addr, auth := range cfg.authsCache {
if toHostname(addr) == serverAddress {
matched = true
authCfgBytes = auth
break
}
}
if !matched {
return auth.EmptyCredential, nil
}
}
var authCfg AuthConfig
if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil {
return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err)
}
return authCfg.Credential()
return getCredentialFromAuthsRaw(cfg.authsCache, serverAddress)
}

// PutAuthConfig puts cred for serverAddress.
Expand Down
35 changes: 35 additions & 0 deletions registry/remote/credentials/internal/config/helpers.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package config

import (
"encoding/json"
"fmt"

"oras.land/oras-go/v2/registry/remote/auth"
)

// getCredentialsFromCache is a helper function to get the credential for serverAddress from authsRaw where key
// is an address and value is a raw JSON content.
func getCredentialFromAuthsRaw(authsRaw map[string]json.RawMessage, serverAddress string) (auth.Credential, error) {
authCfgBytes, ok := authsRaw[serverAddress]
if !ok {
// NOTE: the auth key for the server address may have been stored with
// a http/https prefix in legacy config files, e.g. "registry.example.com"
// can be stored as "https://registry.example.com/".
var matched bool
for addr, auth := range authsRaw {
if toHostname(addr) == serverAddress {
matched = true
authCfgBytes = auth
break
}
}
if !matched {
return auth.EmptyCredential, nil
}
}
var authCfg AuthConfig
if err := json.Unmarshal(authCfgBytes, &authCfg); err != nil {
return auth.EmptyCredential, fmt.Errorf("failed to unmarshal auth field: %w: %v", ErrInvalidConfigFormat, err)
}
return authCfg.Credential()
}
67 changes: 67 additions & 0 deletions registry/remote/credentials/internal/config/readonly_config.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/*
Copyright The ORAS Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package config

import (
"encoding/json"
"fmt"
"io"

"oras.land/oras-go/v2/registry/remote/auth"
)

// ReadOnlyConfig represents authentication credentials parsed from a standard config file,
// which are read to use. It is read-only - only GetCredential is supported.
type ReadOnlyConfig struct {
auths map[string]auth.Credential
}

// LoadFromReader creates a new ReadOnlyConfig from the given reader that contains a standard
// config file content. It returns an error if the content is not in the expected format.
func LoadFromReader(reader io.Reader) (*ReadOnlyConfig, error) {
var content map[string]json.RawMessage
if err := json.NewDecoder(reader).Decode(&content); err != nil {
return nil, err
}
var authsRaw map[string]json.RawMessage
if authsBytes, ok := content[configFieldAuths]; ok {
if err := json.Unmarshal(authsBytes, &authsRaw); err != nil {
return nil, fmt.Errorf("failed to unmarshal auths field: %w: %v", ErrInvalidConfigFormat, err)
}
}

cfg := ReadOnlyConfig{
auths: make(map[string]auth.Credential, len(authsRaw)),
}
for serverAddress := range authsRaw {
creds, err := getCredentialFromAuthsRaw(authsRaw, serverAddress)
if err != nil {
return nil, err
}
cfg.auths[serverAddress] = creds
}

return &cfg, nil
}

// GetCredential returns the credential for the given server address. For non-existent server address,
// it returns auth.EmptyCredential.
func (cfg *ReadOnlyConfig) GetCredential(serverAddress string) (auth.Credential, error) {
if v, ok := cfg.auths[serverAddress]; ok {
return v, nil
}
return auth.EmptyCredential, nil
}
62 changes: 62 additions & 0 deletions registry/remote/credentials/readonly_file_store.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
Copyright The ORAS Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package credentials

import (
"context"
"errors"
"io"

"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/credentials/internal/config"
)

// ReadOnlyFileStore implements a credentials store using the docker configuration file
// as an input. It supports only Get operation that works in the same way as for standard
// FileStore.
type ReadOnlyFileStore struct {
cfg *config.ReadOnlyConfig
}

// ErrReadOnlyStore is returned for operations
// Put(...) and Delete(...) for read-only store.
var ErrReadOnlyStore = errors.New("cannot modify content of the read-only store")

// NewReadOnlyFileStore creates a new file credentials store based on the given config,
// it returns an error if the config is not in the expected format.
func NewReadOnlyFileStore(reader io.Reader) (*ReadOnlyFileStore, error) {
cfg, err := config.LoadFromReader(reader)
if err != nil {
return nil, err
}
return &ReadOnlyFileStore{cfg: cfg}, nil
}

// Get retrieves credentials from the store for the given server address. In case of non-existent
// server address, it returns auth.EmptyCredential.
func (fs *ReadOnlyFileStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) {
return fs.cfg.GetCredential(serverAddress)
}

// Get always returns ErrReadOnlyStore. It's present to satisfy the Store interface.
func (fs *ReadOnlyFileStore) Put(_ context.Context, _ string, _ auth.Credential) error {
return ErrReadOnlyStore
}

// Delete always returns ErrReadOnlyStore. It's present to satisfy the Store interface.
func (fs *ReadOnlyFileStore) Delete(_ context.Context, _ string) error {
return ErrReadOnlyStore
}

0 comments on commit 0ad13e6

Please sign in to comment.