working commit
This commit is contained in:
+276
@@ -0,0 +1,276 @@
|
||||
/*
|
||||
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 registry
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
)
|
||||
|
||||
// regular expressions for components.
|
||||
var (
|
||||
// repositoryRegexp is adapted from the distribution implementation. The
|
||||
// repository name set under OCI distribution spec is a subset of the docker
|
||||
// spec. For maximum compatability, the docker spec is verified client-side.
|
||||
// Further checks are left to the server-side.
|
||||
//
|
||||
// References:
|
||||
// - https://github.com/distribution/distribution/blob/v2.7.1/reference/regexp.go#L53
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pulling-manifests
|
||||
repositoryRegexp = regexp.MustCompile(`^[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*(?:/[a-z0-9]+(?:(?:[._]|__|[-]*)[a-z0-9]+)*)*$`)
|
||||
|
||||
// tagRegexp checks the tag name.
|
||||
// The docker and OCI spec have the same regular expression.
|
||||
//
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#pulling-manifests
|
||||
tagRegexp = regexp.MustCompile(`^[\w][\w.-]{0,127}$`)
|
||||
)
|
||||
|
||||
// Reference references either a resource descriptor (where Reference.Reference
|
||||
// is a tag or a digest), or a resource repository (where Reference.Reference
|
||||
// is the empty string).
|
||||
type Reference struct {
|
||||
// Registry is the name of the registry. It is usually the domain name of
|
||||
// the registry optionally with a port.
|
||||
Registry string
|
||||
|
||||
// Repository is the name of the repository.
|
||||
Repository string
|
||||
|
||||
// Reference is the reference of the object in the repository. This field
|
||||
// can take any one of the four valid forms (see ParseReference). In the
|
||||
// case where it's the empty string, it necessarily implies valid form D,
|
||||
// and where it is non-empty, then it is either a tag, or a digest
|
||||
// (implying one of valid forms A, B, or C).
|
||||
Reference string
|
||||
}
|
||||
|
||||
// ParseReference parses a string (artifact) into an `artifact reference`.
|
||||
// Corresponding cryptographic hash implementations are required to be imported
|
||||
// as specified by https://pkg.go.dev/github.com/opencontainers/go-digest#readme-usage
|
||||
// if the string contains a digest.
|
||||
//
|
||||
// Note: An "image" is an "artifact", however, an "artifact" is not necessarily
|
||||
// an "image".
|
||||
//
|
||||
// The token `artifact` is composed of other tokens, and those in turn are
|
||||
// composed of others. This definition recursivity requires a notation capable
|
||||
// of recursion, thus the following two forms have been adopted:
|
||||
//
|
||||
// 1. Backus–Naur Form (BNF) has been adopted to address the recursive nature
|
||||
// of the definition.
|
||||
// 2. Token opacity is revealed via its label letter-casing. That is, "opaque"
|
||||
// tokens (i.e., tokens that are not final, and must therefore be further
|
||||
// broken down into their constituents) are denoted in *lowercase*, while
|
||||
// final tokens (i.e., leaf-node tokens that are final) are denoted in
|
||||
// *uppercase*.
|
||||
//
|
||||
// Finally, note that a number of the opaque tokens are polymorphic in nature;
|
||||
// that is, they can take on one of numerous forms, not restricted to a single
|
||||
// defining form.
|
||||
//
|
||||
// The top-level token, `artifact`, is composed of two (opaque) tokens; namely
|
||||
// `socketaddr` and `path`:
|
||||
//
|
||||
// <artifact> ::= <socketaddr> "/" <path>
|
||||
//
|
||||
// The former is described as follows:
|
||||
//
|
||||
// <socketaddr> ::= <host> | <host> ":" <PORT>
|
||||
// <host> ::= <ip> | <FQDN>
|
||||
// <ip> ::= <IPV4-ADDR> | <IPV6-ADDR>
|
||||
//
|
||||
// The latter, which is of greater interest here, is described as follows:
|
||||
//
|
||||
// <path> ::= <REPOSITORY> | <REPOSITORY> <reference>
|
||||
// <reference> ::= "@" <digest> | ":" <TAG> "@" <DIGEST> | ":" <TAG>
|
||||
// <digest> ::= <ALGO> ":" <HASH>
|
||||
//
|
||||
// This second token--`path`--can take on exactly four forms, each of which will
|
||||
// now be illustrated:
|
||||
//
|
||||
// <--- path --------------------------------------------> | - Decode `path`
|
||||
// <=== REPOSITORY ===> <--- reference ------------------> | - Decode `reference`
|
||||
// <=== REPOSITORY ===> @ <=================== digest ===> | - Valid Form A
|
||||
// <=== REPOSITORY ===> : <!!! TAG !!!> @ <=== digest ===> | - Valid Form B (tag is dropped)
|
||||
// <=== REPOSITORY ===> : <=== TAG ======================> | - Valid Form C
|
||||
// <=== REPOSITORY ======================================> | - Valid Form D
|
||||
//
|
||||
// Note: In the case of Valid Form B, TAG is dropped without any validation or
|
||||
// further consideration.
|
||||
func ParseReference(artifact string) (Reference, error) {
|
||||
parts := strings.SplitN(artifact, "/", 2)
|
||||
if len(parts) == 1 {
|
||||
// Invalid Form
|
||||
return Reference{}, fmt.Errorf("%w: missing registry or repository", errdef.ErrInvalidReference)
|
||||
}
|
||||
registry, path := parts[0], parts[1]
|
||||
|
||||
var isTag bool
|
||||
var repository string
|
||||
var reference string
|
||||
if index := strings.Index(path, "@"); index != -1 {
|
||||
// `digest` found; Valid Form A (if not B)
|
||||
isTag = false
|
||||
repository = path[:index]
|
||||
reference = path[index+1:]
|
||||
|
||||
if index = strings.Index(repository, ":"); index != -1 {
|
||||
// `tag` found (and now dropped without validation) since `the
|
||||
// `digest` already present; Valid Form B
|
||||
repository = repository[:index]
|
||||
}
|
||||
} else if index = strings.Index(path, ":"); index != -1 {
|
||||
// `tag` found; Valid Form C
|
||||
isTag = true
|
||||
repository = path[:index]
|
||||
reference = path[index+1:]
|
||||
} else {
|
||||
// empty `reference`; Valid Form D
|
||||
repository = path
|
||||
}
|
||||
ref := Reference{
|
||||
Registry: registry,
|
||||
Repository: repository,
|
||||
Reference: reference,
|
||||
}
|
||||
|
||||
if err := ref.ValidateRegistry(); err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
|
||||
if err := ref.ValidateRepository(); err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
|
||||
if len(ref.Reference) == 0 {
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
validator := ref.ValidateReferenceAsDigest
|
||||
if isTag {
|
||||
validator = ref.ValidateReferenceAsTag
|
||||
}
|
||||
if err := validator(); err != nil {
|
||||
return Reference{}, err
|
||||
}
|
||||
|
||||
return ref, nil
|
||||
}
|
||||
|
||||
// Validate the entire reference object; the registry, the repository, and the
|
||||
// reference.
|
||||
func (r Reference) Validate() error {
|
||||
if err := r.ValidateRegistry(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := r.ValidateRepository(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return r.ValidateReference()
|
||||
}
|
||||
|
||||
// ValidateRegistry validates the registry.
|
||||
func (r Reference) ValidateRegistry() error {
|
||||
if uri, err := url.ParseRequestURI("dummy://" + r.Registry); err != nil || uri.Host == "" || uri.Host != r.Registry {
|
||||
return fmt.Errorf("%w: invalid registry %q", errdef.ErrInvalidReference, r.Registry)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateRepository validates the repository.
|
||||
func (r Reference) ValidateRepository() error {
|
||||
if !repositoryRegexp.MatchString(r.Repository) {
|
||||
return fmt.Errorf("%w: invalid repository %q", errdef.ErrInvalidReference, r.Repository)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateReferenceAsTag validates the reference as a tag.
|
||||
func (r Reference) ValidateReferenceAsTag() error {
|
||||
if !tagRegexp.MatchString(r.Reference) {
|
||||
return fmt.Errorf("%w: invalid tag %q", errdef.ErrInvalidReference, r.Reference)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateReferenceAsDigest validates the reference as a digest.
|
||||
func (r Reference) ValidateReferenceAsDigest() error {
|
||||
if _, err := r.Digest(); err != nil {
|
||||
return fmt.Errorf("%w: invalid digest %q: %v", errdef.ErrInvalidReference, r.Reference, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateReference where the reference is first tried as an ampty string, then
|
||||
// as a digest, and if that fails, as a tag.
|
||||
func (r Reference) ValidateReference() error {
|
||||
if len(r.Reference) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if index := strings.IndexByte(r.Reference, ':'); index != -1 {
|
||||
return r.ValidateReferenceAsDigest()
|
||||
}
|
||||
|
||||
return r.ValidateReferenceAsTag()
|
||||
}
|
||||
|
||||
// Host returns the host name of the registry.
|
||||
func (r Reference) Host() string {
|
||||
if r.Registry == "docker.io" {
|
||||
return "registry-1.docker.io"
|
||||
}
|
||||
return r.Registry
|
||||
}
|
||||
|
||||
// ReferenceOrDefault returns the reference or the default reference if empty.
|
||||
func (r Reference) ReferenceOrDefault() string {
|
||||
if r.Reference == "" {
|
||||
return "latest"
|
||||
}
|
||||
return r.Reference
|
||||
}
|
||||
|
||||
// Digest returns the reference as a digest.
|
||||
// Corresponding cryptographic hash implementations are required to be imported
|
||||
// as specified by https://pkg.go.dev/github.com/opencontainers/go-digest#readme-usage
|
||||
func (r Reference) Digest() (digest.Digest, error) {
|
||||
return digest.Parse(r.Reference)
|
||||
}
|
||||
|
||||
// String implements `fmt.Stringer` and returns the reference string.
|
||||
// The resulted string is meaningful only if the reference is valid.
|
||||
func (r Reference) String() string {
|
||||
if r.Repository == "" {
|
||||
return r.Registry
|
||||
}
|
||||
ref := r.Registry + "/" + r.Repository
|
||||
if r.Reference == "" {
|
||||
return ref
|
||||
}
|
||||
if d, err := r.Digest(); err == nil {
|
||||
return ref + "@" + d.String()
|
||||
}
|
||||
return ref + ":" + r.Reference
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
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 registry provides high-level operations to manage registries.
|
||||
package registry
|
||||
|
||||
import "context"
|
||||
|
||||
// Registry represents a collection of repositories.
|
||||
type Registry interface {
|
||||
// Repositories lists the name of repositories available in the registry.
|
||||
// Since the returned repositories may be paginated by the underlying
|
||||
// implementation, a function should be passed in to process the paginated
|
||||
// repository list.
|
||||
// `last` argument is the `last` parameter when invoking the catalog API.
|
||||
// If `last` is NOT empty, the entries in the response start after the
|
||||
// repo specified by `last`. Otherwise, the response starts from the top
|
||||
// of the Repositories list.
|
||||
// Note: When implemented by a remote registry, the catalog API is called.
|
||||
// However, not all registries supports pagination or conforms the
|
||||
// specification.
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#catalog
|
||||
// See also `Repositories()` in this package.
|
||||
Repositories(ctx context.Context, last string, fn func(repos []string) error) error
|
||||
|
||||
// Repository returns a repository reference by the given name.
|
||||
Repository(ctx context.Context, name string) (Repository, error)
|
||||
}
|
||||
|
||||
// Repositories lists the name of repositories available in the registry.
|
||||
func Repositories(ctx context.Context, reg Registry) ([]string, error) {
|
||||
var res []string
|
||||
if err := reg.Repositories(ctx, "", func(repos []string) error {
|
||||
res = append(res, repos...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
/*
|
||||
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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/syncutil"
|
||||
)
|
||||
|
||||
// DefaultCache is the sharable cache used by DefaultClient.
|
||||
var DefaultCache Cache = NewCache()
|
||||
|
||||
// Cache caches the auth-scheme and auth-token for the "Authorization" header in
|
||||
// accessing the remote registry.
|
||||
// Precisely, the header is `Authorization: auth-scheme auth-token`.
|
||||
// The `auth-token` is a generic term as `token68` in RFC 7235 section 2.1.
|
||||
type Cache interface {
|
||||
// GetScheme returns the auth-scheme part cached for the given registry.
|
||||
// A single registry is assumed to have a consistent scheme.
|
||||
// If a registry has different schemes per path, the auth client is still
|
||||
// workable. However, the cache may not be effective as the cache cannot
|
||||
// correctly guess the scheme.
|
||||
GetScheme(ctx context.Context, registry string) (Scheme, error)
|
||||
|
||||
// GetToken returns the auth-token part cached for the given registry of a
|
||||
// given scheme.
|
||||
// The underlying implementation MAY cache the token for all schemes for the
|
||||
// given registry.
|
||||
GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error)
|
||||
|
||||
// Set fetches the token using the given fetch function and caches the token
|
||||
// for the given scheme with the given key for the given registry.
|
||||
// The return values of the fetch function is returned by this function.
|
||||
// The underlying implementation MAY combine the fetch operation if the Set
|
||||
// function is invoked multiple times at the same time.
|
||||
Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error)
|
||||
}
|
||||
|
||||
// cacheEntry is a cache entry for a single registry.
|
||||
type cacheEntry struct {
|
||||
scheme Scheme
|
||||
tokens sync.Map // map[string]string
|
||||
}
|
||||
|
||||
// concurrentCache is a cache suitable for concurrent invocation.
|
||||
type concurrentCache struct {
|
||||
status sync.Map // map[string]*syncutil.Once
|
||||
cache sync.Map // map[string]*cacheEntry
|
||||
}
|
||||
|
||||
// NewCache creates a new go-routine safe cache instance.
|
||||
func NewCache() Cache {
|
||||
return &concurrentCache{}
|
||||
}
|
||||
|
||||
// GetScheme returns the auth-scheme part cached for the given registry.
|
||||
func (cc *concurrentCache) GetScheme(ctx context.Context, registry string) (Scheme, error) {
|
||||
entry, ok := cc.cache.Load(registry)
|
||||
if !ok {
|
||||
return SchemeUnknown, errdef.ErrNotFound
|
||||
}
|
||||
return entry.(*cacheEntry).scheme, nil
|
||||
}
|
||||
|
||||
// GetToken returns the auth-token part cached for the given registry of a given
|
||||
// scheme.
|
||||
func (cc *concurrentCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) {
|
||||
entryValue, ok := cc.cache.Load(registry)
|
||||
if !ok {
|
||||
return "", errdef.ErrNotFound
|
||||
}
|
||||
entry := entryValue.(*cacheEntry)
|
||||
if entry.scheme != scheme {
|
||||
return "", errdef.ErrNotFound
|
||||
}
|
||||
if token, ok := entry.tokens.Load(key); ok {
|
||||
return token.(string), nil
|
||||
}
|
||||
return "", errdef.ErrNotFound
|
||||
}
|
||||
|
||||
// Set fetches the token using the given fetch function and caches the token
|
||||
// for the given scheme with the given key for the given registry.
|
||||
// Set combines the fetch operation if the Set is invoked multiple times at the
|
||||
// same time.
|
||||
func (cc *concurrentCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) {
|
||||
// fetch token
|
||||
statusKey := strings.Join([]string{
|
||||
registry,
|
||||
scheme.String(),
|
||||
key,
|
||||
}, " ")
|
||||
statusValue, _ := cc.status.LoadOrStore(statusKey, syncutil.NewOnce())
|
||||
fetchOnce := statusValue.(*syncutil.Once)
|
||||
fetchedFirst, result, err := fetchOnce.Do(ctx, func() (interface{}, error) {
|
||||
return fetch(ctx)
|
||||
})
|
||||
if fetchedFirst {
|
||||
cc.status.Delete(statusKey)
|
||||
}
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
token := result.(string)
|
||||
if !fetchedFirst {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// cache token
|
||||
newEntry := &cacheEntry{
|
||||
scheme: scheme,
|
||||
}
|
||||
entryValue, exists := cc.cache.LoadOrStore(registry, newEntry)
|
||||
entry := entryValue.(*cacheEntry)
|
||||
if exists && entry.scheme != scheme {
|
||||
// there is a scheme change, which is not expected in most scenarios.
|
||||
// force invalidating all previous cache.
|
||||
entry = newEntry
|
||||
cc.cache.Store(registry, entry)
|
||||
}
|
||||
entry.tokens.Store(key, token)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// noCache is a cache implementation that does not do cache at all.
|
||||
type noCache struct{}
|
||||
|
||||
// GetScheme always returns not found error as it has no cache.
|
||||
func (noCache) GetScheme(ctx context.Context, registry string) (Scheme, error) {
|
||||
return SchemeUnknown, errdef.ErrNotFound
|
||||
}
|
||||
|
||||
// GetToken always returns not found error as it has no cache.
|
||||
func (noCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) {
|
||||
return "", errdef.ErrNotFound
|
||||
}
|
||||
|
||||
// Set calls fetch directly without caching.
|
||||
func (noCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) {
|
||||
return fetch(ctx)
|
||||
}
|
||||
|
||||
// hostCache is an auth cache that ignores scopes. Uses only the registry's hostname to find a token.
|
||||
type hostCache struct {
|
||||
Cache
|
||||
}
|
||||
|
||||
// GetToken implements Cache.
|
||||
func (c *hostCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) {
|
||||
return c.Cache.GetToken(ctx, registry, scheme, "")
|
||||
}
|
||||
|
||||
// Set implements Cache.
|
||||
func (c *hostCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) {
|
||||
return c.Cache.Set(ctx, registry, scheme, "", fetch)
|
||||
}
|
||||
|
||||
// fallbackCache tries the primary cache then falls back to the secondary cache.
|
||||
type fallbackCache struct {
|
||||
primary Cache
|
||||
secondary Cache
|
||||
}
|
||||
|
||||
// GetScheme implements Cache.
|
||||
func (fc *fallbackCache) GetScheme(ctx context.Context, registry string) (Scheme, error) {
|
||||
scheme, err := fc.primary.GetScheme(ctx, registry)
|
||||
if err == nil {
|
||||
return scheme, nil
|
||||
}
|
||||
|
||||
// fallback
|
||||
return fc.secondary.GetScheme(ctx, registry)
|
||||
}
|
||||
|
||||
// GetToken implements Cache.
|
||||
func (fc *fallbackCache) GetToken(ctx context.Context, registry string, scheme Scheme, key string) (string, error) {
|
||||
token, err := fc.primary.GetToken(ctx, registry, scheme, key)
|
||||
if err == nil {
|
||||
return token, nil
|
||||
}
|
||||
|
||||
// fallback
|
||||
return fc.secondary.GetToken(ctx, registry, scheme, key)
|
||||
}
|
||||
|
||||
// Set implements Cache.
|
||||
func (fc *fallbackCache) Set(ctx context.Context, registry string, scheme Scheme, key string, fetch func(context.Context) (string, error)) (string, error) {
|
||||
token, err := fc.primary.Set(ctx, registry, scheme, key, fetch)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return fc.secondary.Set(ctx, registry, scheme, key, func(ctx context.Context) (string, error) {
|
||||
return token, nil
|
||||
})
|
||||
}
|
||||
|
||||
// NewSingleContextCache creates a host-based cache for optimizing the auth flow for non-compliant registries.
|
||||
// It is intended to be used in a single context, such as pulling from a single repository.
|
||||
// This cache should not be shared.
|
||||
//
|
||||
// Note: [NewCache] should be used for compliant registries as it can be shared
|
||||
// across context and will generally make less re-authentication requests.
|
||||
func NewSingleContextCache() Cache {
|
||||
cache := NewCache()
|
||||
return &fallbackCache{
|
||||
primary: cache,
|
||||
// We can re-use the came concurrentCache here because the key space is different
|
||||
// (keys are always empty for the hostCache) so there is no collision.
|
||||
// Even if there is a collision it is not an issue.
|
||||
// Re-using saves a little memory.
|
||||
secondary: &hostCache{cache},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
/*
|
||||
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 auth
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Scheme define the authentication method.
|
||||
type Scheme byte
|
||||
|
||||
const (
|
||||
// SchemeUnknown represents unknown or unsupported schemes
|
||||
SchemeUnknown Scheme = iota
|
||||
|
||||
// SchemeBasic represents the "Basic" HTTP authentication scheme.
|
||||
// Reference: https://tools.ietf.org/html/rfc7617
|
||||
SchemeBasic
|
||||
|
||||
// SchemeBearer represents the Bearer token in OAuth 2.0.
|
||||
// Reference: https://tools.ietf.org/html/rfc6750
|
||||
SchemeBearer
|
||||
)
|
||||
|
||||
// parseScheme parse the authentication scheme from the given string
|
||||
// case-insensitively.
|
||||
func parseScheme(scheme string) Scheme {
|
||||
switch {
|
||||
case strings.EqualFold(scheme, "basic"):
|
||||
return SchemeBasic
|
||||
case strings.EqualFold(scheme, "bearer"):
|
||||
return SchemeBearer
|
||||
}
|
||||
return SchemeUnknown
|
||||
}
|
||||
|
||||
// String return the string for the scheme.
|
||||
func (s Scheme) String() string {
|
||||
switch s {
|
||||
case SchemeBasic:
|
||||
return "Basic"
|
||||
case SchemeBearer:
|
||||
return "Bearer"
|
||||
}
|
||||
return "Unknown"
|
||||
}
|
||||
|
||||
// parseChallenge parses the "WWW-Authenticate" header returned by the remote
|
||||
// registry, and extracts parameters if scheme is Bearer.
|
||||
// References:
|
||||
// - https://distribution.github.io/distribution/spec/auth/token/#how-to-authenticate
|
||||
// - https://tools.ietf.org/html/rfc7235#section-2.1
|
||||
func parseChallenge(header string) (scheme Scheme, params map[string]string) {
|
||||
// as defined in RFC 7235 section 2.1, we have
|
||||
// challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
|
||||
// auth-scheme = token
|
||||
// auth-param = token BWS "=" BWS ( token / quoted-string )
|
||||
//
|
||||
// since we focus parameters only on Bearer, we have
|
||||
// challenge = auth-scheme [ 1*SP #auth-param ]
|
||||
schemeString, rest := parseToken(header)
|
||||
scheme = parseScheme(schemeString)
|
||||
|
||||
// fast path for non bearer challenge
|
||||
if scheme != SchemeBearer {
|
||||
return
|
||||
}
|
||||
|
||||
// parse params for bearer auth.
|
||||
// combining RFC 7235 section 2.1 with RFC 7230 section 7, we have
|
||||
// #auth-param => auth-param *( OWS "," OWS auth-param )
|
||||
var key, value string
|
||||
for {
|
||||
key, rest = parseToken(skipSpace(rest))
|
||||
if key == "" {
|
||||
return
|
||||
}
|
||||
|
||||
rest = skipSpace(rest)
|
||||
if rest == "" || rest[0] != '=' {
|
||||
return
|
||||
}
|
||||
rest = skipSpace(rest[1:])
|
||||
if rest == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if rest[0] == '"' {
|
||||
prefix, err := strconv.QuotedPrefix(rest)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
value, err = strconv.Unquote(prefix)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
rest = rest[len(prefix):]
|
||||
} else {
|
||||
value, rest = parseToken(rest)
|
||||
if value == "" {
|
||||
return
|
||||
}
|
||||
}
|
||||
if params == nil {
|
||||
params = map[string]string{
|
||||
key: value,
|
||||
}
|
||||
} else {
|
||||
params[key] = value
|
||||
}
|
||||
|
||||
rest = skipSpace(rest)
|
||||
if rest == "" || rest[0] != ',' {
|
||||
return
|
||||
}
|
||||
rest = rest[1:]
|
||||
}
|
||||
}
|
||||
|
||||
// isNotTokenChar reports whether rune is not a `tchar` defined in RFC 7230
|
||||
// section 3.2.6.
|
||||
func isNotTokenChar(r rune) bool {
|
||||
// tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
|
||||
// / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
|
||||
// / DIGIT / ALPHA
|
||||
// ; any VCHAR, except delimiters
|
||||
return (r < 'A' || r > 'Z') && (r < 'a' || r > 'z') &&
|
||||
(r < '0' || r > '9') && !strings.ContainsRune("!#$%&'*+-.^_`|~", r)
|
||||
}
|
||||
|
||||
// parseToken finds the next token from the given string. If no token found,
|
||||
// an empty token is returned and the whole of the input is returned in rest.
|
||||
// Note: Since token = 1*tchar, empty string is not a valid token.
|
||||
func parseToken(s string) (token, rest string) {
|
||||
if i := strings.IndexFunc(s, isNotTokenChar); i != -1 {
|
||||
return s[:i], s[i:]
|
||||
}
|
||||
return s, ""
|
||||
}
|
||||
|
||||
// skipSpace skips "bad" whitespace (BWS) defined in RFC 7230 section 3.2.3.
|
||||
func skipSpace(s string) string {
|
||||
// OWS = *( SP / HTAB )
|
||||
// ; optional whitespace
|
||||
// BWS = OWS
|
||||
// ; "bad" whitespace
|
||||
if i := strings.IndexFunc(s, func(r rune) bool {
|
||||
return r != ' ' && r != '\t'
|
||||
}); i != -1 {
|
||||
return s[i:]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,430 @@
|
||||
/*
|
||||
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 auth provides authentication for a client to a remote registry.
|
||||
package auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/internal/errutil"
|
||||
"oras.land/oras-go/v2/registry/remote/retry"
|
||||
)
|
||||
|
||||
// ErrBasicCredentialNotFound is returned when the credential is not found for
|
||||
// basic auth.
|
||||
var ErrBasicCredentialNotFound = errors.New("basic credential not found")
|
||||
|
||||
// DefaultClient is the default auth-decorated client.
|
||||
var DefaultClient = &Client{
|
||||
Client: retry.DefaultClient,
|
||||
Header: http.Header{
|
||||
"User-Agent": {"oras-go"},
|
||||
},
|
||||
Cache: DefaultCache,
|
||||
}
|
||||
|
||||
// maxResponseBytes specifies the default limit on how many response bytes are
|
||||
// allowed in the server's response from authorization service servers.
|
||||
// A typical response message from authorization service servers is around 1 to
|
||||
// 4 KiB. Since the size of a token must be smaller than the HTTP header size
|
||||
// limit, which is usually 16 KiB. As specified by the distribution, the
|
||||
// response may contain 2 identical tokens, that is, 16 x 2 = 32 KiB.
|
||||
// Hence, 128 KiB should be sufficient.
|
||||
// References: https://distribution.github.io/distribution/spec/auth/token/
|
||||
var maxResponseBytes int64 = 128 * 1024 // 128 KiB
|
||||
|
||||
// defaultClientID specifies the default client ID used in OAuth2.
|
||||
// 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) 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, hostport string) (Credential, error) {
|
||||
if hostport == registry {
|
||||
return cred, nil
|
||||
}
|
||||
return EmptyCredential, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Client is an auth-decorated HTTP client.
|
||||
// Its zero value is a usable client that uses http.DefaultClient with no cache.
|
||||
type Client struct {
|
||||
// Client is the underlying HTTP client used to access the remote
|
||||
// server.
|
||||
// If nil, http.DefaultClient is used.
|
||||
// It is possible to use the default retry client from the package
|
||||
// `oras.land/oras-go/v2/registry/remote/retry`. That client is already available
|
||||
// in the DefaultClient.
|
||||
// It is also possible to use a custom client. For example, github.com/hashicorp/go-retryablehttp
|
||||
// is a popular HTTP client that supports retries.
|
||||
Client *http.Client
|
||||
|
||||
// Header contains the custom headers to be added to each request.
|
||||
Header http.Header
|
||||
|
||||
// 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
|
||||
// an 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.
|
||||
Cache Cache
|
||||
|
||||
// ClientID used in fetching OAuth2 token as a required field.
|
||||
// If empty, a default client ID is used.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/oauth/#getting-a-token
|
||||
ClientID string
|
||||
|
||||
// ForceAttemptOAuth2 controls whether to follow OAuth2 with password grant
|
||||
// instead the distribution spec when authenticating using username and
|
||||
// password.
|
||||
// References:
|
||||
// - https://distribution.github.io/distribution/spec/auth/jwt/
|
||||
// - https://distribution.github.io/distribution/spec/auth/oauth/
|
||||
ForceAttemptOAuth2 bool
|
||||
}
|
||||
|
||||
// client returns an HTTP client used to access the remote registry.
|
||||
// http.DefaultClient is return if the client is not configured.
|
||||
func (c *Client) client() *http.Client {
|
||||
if c.Client == nil {
|
||||
return http.DefaultClient
|
||||
}
|
||||
return c.Client
|
||||
}
|
||||
|
||||
// send adds headers to the request and sends the request to the remote server.
|
||||
func (c *Client) send(req *http.Request) (*http.Response, error) {
|
||||
for key, values := range c.Header {
|
||||
req.Header[key] = append(req.Header[key], values...)
|
||||
}
|
||||
return c.client().Do(req)
|
||||
}
|
||||
|
||||
// credential resolves the credential for the given registry.
|
||||
func (c *Client) credential(ctx context.Context, reg string) (Credential, error) {
|
||||
if c.Credential == nil {
|
||||
return EmptyCredential, nil
|
||||
}
|
||||
return c.Credential(ctx, reg)
|
||||
}
|
||||
|
||||
// cache resolves the cache.
|
||||
// noCache is return if the cache is not configured.
|
||||
func (c *Client) cache() Cache {
|
||||
if c.Cache == nil {
|
||||
return noCache{}
|
||||
}
|
||||
return c.Cache
|
||||
}
|
||||
|
||||
// SetUserAgent sets the user agent for all out-going requests.
|
||||
func (c *Client) SetUserAgent(userAgent string) {
|
||||
if c.Header == nil {
|
||||
c.Header = http.Header{}
|
||||
}
|
||||
c.Header.Set("User-Agent", userAgent)
|
||||
}
|
||||
|
||||
// Do sends the request to the remote server, attempting to resolve
|
||||
// authentication if 'Authorization' header is not set.
|
||||
//
|
||||
// On authentication failure due to bad credential,
|
||||
// - Do returns error if it fails to fetch token for bearer auth.
|
||||
// - Do returns the registry response without error for basic auth.
|
||||
func (c *Client) Do(originalReq *http.Request) (*http.Response, error) {
|
||||
if auth := originalReq.Header.Get("Authorization"); auth != "" {
|
||||
return c.send(originalReq)
|
||||
}
|
||||
|
||||
ctx := originalReq.Context()
|
||||
req := originalReq.Clone(ctx)
|
||||
|
||||
// attempt cached auth token
|
||||
var attemptedKey string
|
||||
cache := c.cache()
|
||||
host := originalReq.Host
|
||||
scheme, err := cache.GetScheme(ctx, host)
|
||||
if err == nil {
|
||||
switch scheme {
|
||||
case SchemeBasic:
|
||||
token, err := cache.GetToken(ctx, host, SchemeBasic, "")
|
||||
if err == nil {
|
||||
req.Header.Set("Authorization", "Basic "+token)
|
||||
}
|
||||
case SchemeBearer:
|
||||
scopes := GetAllScopesForHost(ctx, host)
|
||||
attemptedKey = strings.Join(scopes, " ")
|
||||
token, err := cache.GetToken(ctx, host, SchemeBearer, attemptedKey)
|
||||
if err == nil {
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
resp, err := c.send(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// attempt again with credentials for recognized schemes
|
||||
challenge := resp.Header.Get("Www-Authenticate")
|
||||
scheme, params := parseChallenge(challenge)
|
||||
switch scheme {
|
||||
case SchemeBasic:
|
||||
resp.Body.Close()
|
||||
|
||||
token, err := cache.Set(ctx, host, SchemeBasic, "", func(ctx context.Context) (string, error) {
|
||||
return c.fetchBasicAuth(ctx, host)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
|
||||
req = originalReq.Clone(ctx)
|
||||
req.Header.Set("Authorization", "Basic "+token)
|
||||
case SchemeBearer:
|
||||
resp.Body.Close()
|
||||
|
||||
scopes := GetAllScopesForHost(ctx, host)
|
||||
if paramScope := params["scope"]; paramScope != "" {
|
||||
// merge hinted scopes with challenged scopes
|
||||
scopes = append(scopes, strings.Split(paramScope, " ")...)
|
||||
scopes = CleanScopes(scopes)
|
||||
}
|
||||
key := strings.Join(scopes, " ")
|
||||
|
||||
// attempt the cache again if there is a scope change
|
||||
if key != attemptedKey {
|
||||
if token, err := cache.GetToken(ctx, host, SchemeBearer, key); err == nil {
|
||||
req = originalReq.Clone(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
if err := rewindRequestBody(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
resp, err := c.send(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if resp.StatusCode != http.StatusUnauthorized {
|
||||
return resp, nil
|
||||
}
|
||||
resp.Body.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// attempt with credentials
|
||||
realm := params["realm"]
|
||||
service := params["service"]
|
||||
token, err := cache.Set(ctx, host, SchemeBearer, key, func(ctx context.Context) (string, error) {
|
||||
return c.fetchBearerToken(ctx, host, realm, service, scopes)
|
||||
})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s %q: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
|
||||
req = originalReq.Clone(ctx)
|
||||
req.Header.Set("Authorization", "Bearer "+token)
|
||||
default:
|
||||
return resp, nil
|
||||
}
|
||||
if err := rewindRequestBody(req); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c.send(req)
|
||||
}
|
||||
|
||||
// fetchBasicAuth fetches a basic auth token for the basic challenge.
|
||||
func (c *Client) fetchBasicAuth(ctx context.Context, registry string) (string, error) {
|
||||
cred, err := c.credential(ctx, registry)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to resolve credential: %w", err)
|
||||
}
|
||||
if cred == EmptyCredential {
|
||||
return "", ErrBasicCredentialNotFound
|
||||
}
|
||||
if cred.Username == "" || cred.Password == "" {
|
||||
return "", errors.New("missing username or password for basic auth")
|
||||
}
|
||||
auth := cred.Username + ":" + cred.Password
|
||||
return base64.StdEncoding.EncodeToString([]byte(auth)), nil
|
||||
}
|
||||
|
||||
// fetchBearerToken fetches an access token for the bearer challenge.
|
||||
func (c *Client) fetchBearerToken(ctx context.Context, registry, realm, service string, scopes []string) (string, error) {
|
||||
cred, err := c.credential(ctx, registry)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if cred.AccessToken != "" {
|
||||
return cred.AccessToken, nil
|
||||
}
|
||||
if cred == EmptyCredential || (cred.RefreshToken == "" && !c.ForceAttemptOAuth2) {
|
||||
return c.fetchDistributionToken(ctx, realm, service, scopes, cred.Username, cred.Password)
|
||||
}
|
||||
return c.fetchOAuth2Token(ctx, realm, service, scopes, cred)
|
||||
}
|
||||
|
||||
// fetchDistributionToken fetches an access token as defined by the distribution
|
||||
// specification.
|
||||
// It fetches anonymous tokens if no credential is provided.
|
||||
// References:
|
||||
// - https://distribution.github.io/distribution/spec/auth/jwt/
|
||||
// - https://distribution.github.io/distribution/spec/auth/token/
|
||||
func (c *Client) fetchDistributionToken(ctx context.Context, realm, service string, scopes []string, username, password string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, realm, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if username != "" || password != "" {
|
||||
req.SetBasicAuth(username, password)
|
||||
}
|
||||
q := req.URL.Query()
|
||||
if service != "" {
|
||||
q.Set("service", service)
|
||||
}
|
||||
for _, scope := range scopes {
|
||||
q.Add("scope", scope)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
|
||||
resp, err := c.send(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errutil.ParseErrorResponse(resp)
|
||||
}
|
||||
|
||||
// As specified in https://distribution.github.io/distribution/spec/auth/token/ section
|
||||
// "Token Response Fields", the token is either in `token` or
|
||||
// `access_token`. If both present, they are identical.
|
||||
var result struct {
|
||||
Token string `json:"token"`
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
lr := io.LimitReader(resp.Body, maxResponseBytes)
|
||||
if err := json.NewDecoder(lr).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
if result.AccessToken != "" {
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
if result.Token != "" {
|
||||
return result.Token, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s %q: empty token returned", resp.Request.Method, resp.Request.URL)
|
||||
}
|
||||
|
||||
// fetchOAuth2Token fetches an OAuth2 access token.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/oauth/
|
||||
func (c *Client) fetchOAuth2Token(ctx context.Context, realm, service string, scopes []string, cred Credential) (string, error) {
|
||||
form := url.Values{}
|
||||
if cred.RefreshToken != "" {
|
||||
form.Set("grant_type", "refresh_token")
|
||||
form.Set("refresh_token", cred.RefreshToken)
|
||||
} else if cred.Username != "" && cred.Password != "" {
|
||||
form.Set("grant_type", "password")
|
||||
form.Set("username", cred.Username)
|
||||
form.Set("password", cred.Password)
|
||||
} else {
|
||||
return "", errors.New("missing username or password for bearer auth")
|
||||
}
|
||||
form.Set("service", service)
|
||||
clientID := c.ClientID
|
||||
if clientID == "" {
|
||||
clientID = defaultClientID
|
||||
}
|
||||
form.Set("client_id", clientID)
|
||||
if len(scopes) != 0 {
|
||||
form.Set("scope", strings.Join(scopes, " "))
|
||||
}
|
||||
body := strings.NewReader(form.Encode())
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, realm, body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
|
||||
resp, err := c.send(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errutil.ParseErrorResponse(resp)
|
||||
}
|
||||
|
||||
var result struct {
|
||||
AccessToken string `json:"access_token"`
|
||||
}
|
||||
lr := io.LimitReader(resp.Body, maxResponseBytes)
|
||||
if err := json.NewDecoder(lr).Decode(&result); err != nil {
|
||||
return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
if result.AccessToken != "" {
|
||||
return result.AccessToken, nil
|
||||
}
|
||||
return "", fmt.Errorf("%s %q: empty token returned", resp.Request.Method, resp.Request.URL)
|
||||
}
|
||||
|
||||
// rewindRequestBody tries to rewind the request body if exists.
|
||||
func rewindRequestBody(req *http.Request) error {
|
||||
if req.Body == nil || req.Body == http.NoBody {
|
||||
return nil
|
||||
}
|
||||
if req.GetBody == nil {
|
||||
return fmt.Errorf("%s %q: request body is not rewindable", req.Method, req.URL)
|
||||
}
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s %q: failed to get request body: %w", req.Method, req.URL, err)
|
||||
}
|
||||
req.Body = body
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/*
|
||||
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 auth
|
||||
|
||||
// EmptyCredential represents an empty credential.
|
||||
var EmptyCredential Credential
|
||||
|
||||
// Credential contains authentication credentials used to access remote
|
||||
// registries.
|
||||
type Credential struct {
|
||||
// Username is the name of the user for the remote registry.
|
||||
Username string
|
||||
|
||||
// Password is the secret associated with the username.
|
||||
Password string
|
||||
|
||||
// RefreshToken is a bearer token to be sent to the authorization service
|
||||
// for fetching access tokens.
|
||||
// A refresh token is often referred as an identity token.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/oauth/
|
||||
RefreshToken string
|
||||
|
||||
// AccessToken is a bearer token to be sent to the registry.
|
||||
// An access token is often referred as a registry token.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/token/
|
||||
AccessToken string
|
||||
}
|
||||
@@ -0,0 +1,325 @@
|
||||
/*
|
||||
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 auth
|
||||
|
||||
import (
|
||||
"context"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"oras.land/oras-go/v2/registry"
|
||||
)
|
||||
|
||||
// Actions used in scopes.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/scope/
|
||||
const (
|
||||
// ActionPull represents generic read access for resources of the repository
|
||||
// type.
|
||||
ActionPull = "pull"
|
||||
|
||||
// ActionPush represents generic write access for resources of the
|
||||
// repository type.
|
||||
ActionPush = "push"
|
||||
|
||||
// ActionDelete represents the delete permission for resources of the
|
||||
// repository type.
|
||||
ActionDelete = "delete"
|
||||
)
|
||||
|
||||
// ScopeRegistryCatalog is the scope for registry catalog access.
|
||||
const ScopeRegistryCatalog = "registry:catalog:*"
|
||||
|
||||
// ScopeRepository returns a repository scope with given actions.
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/scope/
|
||||
func ScopeRepository(repository string, actions ...string) string {
|
||||
actions = cleanActions(actions)
|
||||
if repository == "" || len(actions) == 0 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join([]string{
|
||||
"repository",
|
||||
repository,
|
||||
strings.Join(actions, ","),
|
||||
}, ":")
|
||||
}
|
||||
|
||||
// AppendRepositoryScope returns a new context containing scope hints for the
|
||||
// auth client to fetch bearer tokens with the given actions on the repository.
|
||||
// If called multiple times, the new scopes will be appended to the existing
|
||||
// scopes. The resulted scopes are de-duplicated.
|
||||
//
|
||||
// For example, uploading blob to the repository "hello-world" does HEAD request
|
||||
// first then POST and PUT. The HEAD request will return a challenge for scope
|
||||
// `repository:hello-world:pull`, and the auth client will fetch a token for
|
||||
// that challenge. Later, the POST request will return a challenge for scope
|
||||
// `repository:hello-world:push`, and the auth client will fetch a token for
|
||||
// that challenge again. By invoking AppendRepositoryScope with the actions
|
||||
// [ActionPull] and [ActionPush] for the repository `hello-world`,
|
||||
// the auth client with cache is hinted to fetch a token via a single token
|
||||
// fetch request for all the HEAD, POST, PUT requests.
|
||||
func AppendRepositoryScope(ctx context.Context, ref registry.Reference, actions ...string) context.Context {
|
||||
if len(actions) == 0 {
|
||||
return ctx
|
||||
}
|
||||
scope := ScopeRepository(ref.Repository, actions...)
|
||||
return AppendScopesForHost(ctx, ref.Host(), scope)
|
||||
}
|
||||
|
||||
// scopesContextKey is the context key for scopes.
|
||||
type scopesContextKey struct{}
|
||||
|
||||
// WithScopes returns a context with scopes added. Scopes are de-duplicated.
|
||||
// Scopes are used as hints for the auth client to fetch bearer tokens with
|
||||
// larger scopes.
|
||||
//
|
||||
// For example, uploading blob to the repository "hello-world" does HEAD request
|
||||
// first then POST and PUT. The HEAD request will return a challenge for scope
|
||||
// `repository:hello-world:pull`, and the auth client will fetch a token for
|
||||
// that challenge. Later, the POST request will return a challenge for scope
|
||||
// `repository:hello-world:push`, and the auth client will fetch a token for
|
||||
// that challenge again. By invoking WithScopes with the scope
|
||||
// `repository:hello-world:pull,push`, the auth client with cache is hinted to
|
||||
// fetch a token via a single token fetch request for all the HEAD, POST, PUT
|
||||
// requests.
|
||||
//
|
||||
// Passing an empty list of scopes will virtually remove the scope hints in the
|
||||
// context.
|
||||
//
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/scope/
|
||||
func WithScopes(ctx context.Context, scopes ...string) context.Context {
|
||||
scopes = CleanScopes(scopes)
|
||||
return context.WithValue(ctx, scopesContextKey{}, scopes)
|
||||
}
|
||||
|
||||
// AppendScopes appends additional scopes to the existing scopes in the context
|
||||
// and returns a new context. The resulted scopes are de-duplicated.
|
||||
// The append operation does modify the existing scope in the context passed in.
|
||||
func AppendScopes(ctx context.Context, scopes ...string) context.Context {
|
||||
if len(scopes) == 0 {
|
||||
return ctx
|
||||
}
|
||||
return WithScopes(ctx, append(GetScopes(ctx), scopes...)...)
|
||||
}
|
||||
|
||||
// GetScopes returns the scopes in the context.
|
||||
func GetScopes(ctx context.Context) []string {
|
||||
if scopes, ok := ctx.Value(scopesContextKey{}).([]string); ok {
|
||||
return slices.Clone(scopes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// scopesForHostContextKey is the context key for per-host scopes.
|
||||
type scopesForHostContextKey string
|
||||
|
||||
// WithScopesForHost returns a context with per-host scopes added.
|
||||
// Scopes are de-duplicated.
|
||||
// Scopes are used as hints for the auth client to fetch bearer tokens with
|
||||
// larger scopes.
|
||||
//
|
||||
// For example, uploading blob to the repository "hello-world" does HEAD request
|
||||
// first then POST and PUT. The HEAD request will return a challenge for scope
|
||||
// `repository:hello-world:pull`, and the auth client will fetch a token for
|
||||
// that challenge. Later, the POST request will return a challenge for scope
|
||||
// `repository:hello-world:push`, and the auth client will fetch a token for
|
||||
// that challenge again. By invoking WithScopesForHost with the scope
|
||||
// `repository:hello-world:pull,push`, the auth client with cache is hinted to
|
||||
// fetch a token via a single token fetch request for all the HEAD, POST, PUT
|
||||
// requests.
|
||||
//
|
||||
// Passing an empty list of scopes will virtually remove the scope hints in the
|
||||
// context for the given host.
|
||||
//
|
||||
// Reference: https://distribution.github.io/distribution/spec/auth/scope/
|
||||
func WithScopesForHost(ctx context.Context, host string, scopes ...string) context.Context {
|
||||
scopes = CleanScopes(scopes)
|
||||
return context.WithValue(ctx, scopesForHostContextKey(host), scopes)
|
||||
}
|
||||
|
||||
// AppendScopesForHost appends additional scopes to the existing scopes
|
||||
// in the context for the given host and returns a new context.
|
||||
// The resulted scopes are de-duplicated.
|
||||
// The append operation does modify the existing scope in the context passed in.
|
||||
func AppendScopesForHost(ctx context.Context, host string, scopes ...string) context.Context {
|
||||
if len(scopes) == 0 {
|
||||
return ctx
|
||||
}
|
||||
oldScopes := GetScopesForHost(ctx, host)
|
||||
return WithScopesForHost(ctx, host, append(oldScopes, scopes...)...)
|
||||
}
|
||||
|
||||
// GetScopesForHost returns the scopes in the context for the given host,
|
||||
// excluding global scopes added by [WithScopes] and [AppendScopes].
|
||||
func GetScopesForHost(ctx context.Context, host string) []string {
|
||||
if scopes, ok := ctx.Value(scopesForHostContextKey(host)).([]string); ok {
|
||||
return slices.Clone(scopes)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetAllScopesForHost returns the scopes in the context for the given host,
|
||||
// including global scopes added by [WithScopes] and [AppendScopes].
|
||||
func GetAllScopesForHost(ctx context.Context, host string) []string {
|
||||
scopes := GetScopesForHost(ctx, host)
|
||||
globalScopes := GetScopes(ctx)
|
||||
|
||||
if len(scopes) == 0 {
|
||||
return globalScopes
|
||||
}
|
||||
if len(globalScopes) == 0 {
|
||||
return scopes
|
||||
}
|
||||
// re-clean the scopes
|
||||
allScopes := append(scopes, globalScopes...)
|
||||
return CleanScopes(allScopes)
|
||||
}
|
||||
|
||||
// CleanScopes merges and sort the actions in ascending order if the scopes have
|
||||
// the same resource type and name. The final scopes are sorted in ascending
|
||||
// order. In other words, the scopes passed in are de-duplicated and sorted.
|
||||
// Therefore, the output of this function is deterministic.
|
||||
//
|
||||
// If there is a wildcard `*` in the action, other actions in the same resource
|
||||
// type and name are ignored.
|
||||
func CleanScopes(scopes []string) []string {
|
||||
// fast paths
|
||||
switch len(scopes) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
scope := scopes[0]
|
||||
i := strings.LastIndex(scope, ":")
|
||||
if i == -1 {
|
||||
return []string{scope}
|
||||
}
|
||||
actionList := strings.Split(scope[i+1:], ",")
|
||||
actionList = cleanActions(actionList)
|
||||
if len(actionList) == 0 {
|
||||
return nil
|
||||
}
|
||||
actions := strings.Join(actionList, ",")
|
||||
scope = scope[:i+1] + actions
|
||||
return []string{scope}
|
||||
}
|
||||
|
||||
// slow path
|
||||
var result []string
|
||||
|
||||
// merge recognizable scopes
|
||||
resourceTypes := make(map[string]map[string]map[string]struct{})
|
||||
for _, scope := range scopes {
|
||||
// extract resource type
|
||||
i := strings.Index(scope, ":")
|
||||
if i == -1 {
|
||||
result = append(result, scope)
|
||||
continue
|
||||
}
|
||||
resourceType := scope[:i]
|
||||
|
||||
// extract resource name and actions
|
||||
rest := scope[i+1:]
|
||||
i = strings.LastIndex(rest, ":")
|
||||
if i == -1 {
|
||||
result = append(result, scope)
|
||||
continue
|
||||
}
|
||||
resourceName := rest[:i]
|
||||
actions := rest[i+1:]
|
||||
if actions == "" {
|
||||
// drop scope since no action found
|
||||
continue
|
||||
}
|
||||
|
||||
// add to the intermediate map for de-duplication
|
||||
namedActions := resourceTypes[resourceType]
|
||||
if namedActions == nil {
|
||||
namedActions = make(map[string]map[string]struct{})
|
||||
resourceTypes[resourceType] = namedActions
|
||||
}
|
||||
actionSet := namedActions[resourceName]
|
||||
if actionSet == nil {
|
||||
actionSet = make(map[string]struct{})
|
||||
namedActions[resourceName] = actionSet
|
||||
}
|
||||
for _, action := range strings.Split(actions, ",") {
|
||||
if action != "" {
|
||||
actionSet[action] = struct{}{}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reconstruct scopes
|
||||
for resourceType, namedActions := range resourceTypes {
|
||||
for resourceName, actionSet := range namedActions {
|
||||
if len(actionSet) == 0 {
|
||||
continue
|
||||
}
|
||||
var actions []string
|
||||
for action := range actionSet {
|
||||
if action == "*" {
|
||||
actions = []string{"*"}
|
||||
break
|
||||
}
|
||||
actions = append(actions, action)
|
||||
}
|
||||
slices.Sort(actions)
|
||||
scope := resourceType + ":" + resourceName + ":" + strings.Join(actions, ",")
|
||||
result = append(result, scope)
|
||||
}
|
||||
}
|
||||
|
||||
// sort and return
|
||||
slices.Sort(result)
|
||||
return result
|
||||
}
|
||||
|
||||
// cleanActions removes the duplicated actions and sort in ascending order.
|
||||
// If there is a wildcard `*` in the action, other actions are ignored.
|
||||
func cleanActions(actions []string) []string {
|
||||
// fast paths
|
||||
switch len(actions) {
|
||||
case 0:
|
||||
return nil
|
||||
case 1:
|
||||
if actions[0] == "" {
|
||||
return nil
|
||||
}
|
||||
return actions
|
||||
}
|
||||
|
||||
// slow path
|
||||
slices.Sort(actions)
|
||||
n := 0
|
||||
for i := range len(actions) {
|
||||
if actions[i] == "*" {
|
||||
return []string{"*"}
|
||||
}
|
||||
if actions[i] != actions[n] {
|
||||
n++
|
||||
if n != i {
|
||||
actions[n] = actions[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
n++
|
||||
if actions[0] == "" {
|
||||
if n == 1 {
|
||||
return nil
|
||||
}
|
||||
return actions[1:n]
|
||||
}
|
||||
return actions[:n]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
+332
@@ -0,0 +1,332 @@
|
||||
/*
|
||||
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 {
|
||||
// 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()
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// Path returns the path to the config file.
|
||||
func (cfg *Config) Path() string {
|
||||
return cfg.path
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// ToHostname normalizes a server address to just its hostname, removing
|
||||
// the scheme and the path parts.
|
||||
// It is used to match keys in the auths map, which may be either stored as
|
||||
// hostname or as hostname including scheme (in legacy docker config files).
|
||||
// Reference: https://github.com/docker/cli/blob/v24.0.6/cli/config/credentials/file_store.go#L71
|
||||
func ToHostname(addr string) string {
|
||||
addr = strings.TrimPrefix(addr, "http://")
|
||||
addr = strings.TrimPrefix(addr, "https://")
|
||||
addr, _, _ = strings.Cut(addr, "/")
|
||||
return addr
|
||||
}
|
||||
+80
@@ -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
|
||||
}
|
||||
+49
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
/*
|
||||
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"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/credentials/internal/config"
|
||||
)
|
||||
|
||||
// memoryStore is a store that keeps credentials in memory.
|
||||
type memoryStore struct {
|
||||
store sync.Map
|
||||
}
|
||||
|
||||
// NewMemoryStore creates a new in-memory credentials store.
|
||||
func NewMemoryStore() Store {
|
||||
return &memoryStore{}
|
||||
}
|
||||
|
||||
// NewMemoryStoreFromDockerConfig creates a new in-memory credentials store from the given configuration.
|
||||
//
|
||||
// Reference: https://docs.docker.com/engine/reference/commandline/cli/#docker-cli-configuration-file-configjson-properties
|
||||
func NewMemoryStoreFromDockerConfig(c []byte) (Store, error) {
|
||||
cfg := struct {
|
||||
Auths map[string]config.AuthConfig `json:"auths"`
|
||||
}{}
|
||||
if err := json.Unmarshal(c, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal auth field: %w: %v", config.ErrInvalidConfigFormat, err)
|
||||
}
|
||||
|
||||
s := &memoryStore{}
|
||||
for addr, auth := range cfg.Auths {
|
||||
// Normalize the auth key to hostname.
|
||||
hostname := config.ToHostname(addr)
|
||||
cred, err := auth.Credential()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
_, _ = s.store.LoadOrStore(hostname, cred)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// Get retrieves credentials from the store for the given server address.
|
||||
func (ms *memoryStore) Get(_ context.Context, serverAddress string) (auth.Credential, error) {
|
||||
cred, found := ms.store.Load(serverAddress)
|
||||
if !found {
|
||||
return auth.EmptyCredential, nil
|
||||
}
|
||||
return cred.(auth.Credential), nil
|
||||
}
|
||||
|
||||
// Put saves credentials into the store for the given server address.
|
||||
func (ms *memoryStore) Put(_ context.Context, serverAddress string, cred auth.Credential) error {
|
||||
ms.store.Store(serverAddress, cred)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete removes credentials from the store for the given server address.
|
||||
func (ms *memoryStore) Delete(_ context.Context, serverAddress string) error {
|
||||
ms.store.Delete(serverAddress)
|
||||
return nil
|
||||
}
|
||||
@@ -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 = "<token>"
|
||||
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 "<token>"
|
||||
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 ""
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
+25
@@ -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 ""
|
||||
}
|
||||
+29
@@ -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"
|
||||
}
|
||||
+23
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/*
|
||||
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"
|
||||
|
||||
"oras.land/oras-go/v2/internal/syncutil"
|
||||
"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 syncutil.OnceOrRetry
|
||||
}
|
||||
|
||||
// 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) 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
|
||||
return ds.setCredsStoreOnce.Do(func() error {
|
||||
if ds.detectedCredsStore != "" {
|
||||
if err := ds.config.SetCredentialsStore(ds.detectedCredsStore); err != nil {
|
||||
return fmt.Errorf("failed to set credsStore: %w", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
}
|
||||
|
||||
// ConfigPath returns the path to the config file.
|
||||
func (ds *DynamicStore) ConfigPath() string {
|
||||
return ds.config.Path()
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
/*
|
||||
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 errcode
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#error-codes
|
||||
// - https://distribution.github.io/distribution/spec/api/#errors-2
|
||||
const (
|
||||
ErrorCodeBlobUnknown = "BLOB_UNKNOWN"
|
||||
ErrorCodeBlobUploadInvalid = "BLOB_UPLOAD_INVALID"
|
||||
ErrorCodeBlobUploadUnknown = "BLOB_UPLOAD_UNKNOWN"
|
||||
ErrorCodeDigestInvalid = "DIGEST_INVALID"
|
||||
ErrorCodeManifestBlobUnknown = "MANIFEST_BLOB_UNKNOWN"
|
||||
ErrorCodeManifestInvalid = "MANIFEST_INVALID"
|
||||
ErrorCodeManifestUnknown = "MANIFEST_UNKNOWN"
|
||||
ErrorCodeNameInvalid = "NAME_INVALID"
|
||||
ErrorCodeNameUnknown = "NAME_UNKNOWN"
|
||||
ErrorCodeSizeInvalid = "SIZE_INVALID"
|
||||
ErrorCodeUnauthorized = "UNAUTHORIZED"
|
||||
ErrorCodeDenied = "DENIED"
|
||||
ErrorCodeUnsupported = "UNSUPPORTED"
|
||||
)
|
||||
|
||||
// Error represents a response inner error returned by the remote
|
||||
// registry.
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#error-codes
|
||||
// - https://distribution.github.io/distribution/spec/api/#errors-2
|
||||
type Error struct {
|
||||
Code string `json:"code"`
|
||||
Message string `json:"message"`
|
||||
Detail any `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// Error returns a error string describing the error.
|
||||
func (e Error) Error() string {
|
||||
code := strings.Map(func(r rune) rune {
|
||||
if r == '_' {
|
||||
return ' '
|
||||
}
|
||||
return unicode.ToLower(r)
|
||||
}, e.Code)
|
||||
if e.Message == "" {
|
||||
return code
|
||||
}
|
||||
if e.Detail == nil {
|
||||
return fmt.Sprintf("%s: %s", code, e.Message)
|
||||
}
|
||||
return fmt.Sprintf("%s: %s: %v", code, e.Message, e.Detail)
|
||||
}
|
||||
|
||||
// Errors represents a list of response inner errors returned by the remote
|
||||
// server.
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#error-codes
|
||||
// - https://distribution.github.io/distribution/spec/api/#errors-2
|
||||
type Errors []Error
|
||||
|
||||
// Error returns a error string describing the error.
|
||||
func (errs Errors) Error() string {
|
||||
switch len(errs) {
|
||||
case 0:
|
||||
return "<nil>"
|
||||
case 1:
|
||||
return errs[0].Error()
|
||||
}
|
||||
var errmsgs []string
|
||||
for _, err := range errs {
|
||||
errmsgs = append(errmsgs, err.Error())
|
||||
}
|
||||
return strings.Join(errmsgs, "; ")
|
||||
}
|
||||
|
||||
// Unwrap returns the inner error only when there is exactly one error.
|
||||
func (errs Errors) Unwrap() error {
|
||||
if len(errs) == 1 {
|
||||
return errs[0]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ErrorResponse represents an error response.
|
||||
type ErrorResponse struct {
|
||||
Method string
|
||||
URL *url.URL
|
||||
StatusCode int
|
||||
Errors Errors
|
||||
}
|
||||
|
||||
// Error returns a error string describing the error.
|
||||
func (err *ErrorResponse) Error() string {
|
||||
var errmsg string
|
||||
if len(err.Errors) > 0 {
|
||||
errmsg = err.Errors.Error()
|
||||
} else {
|
||||
errmsg = http.StatusText(err.StatusCode)
|
||||
}
|
||||
return fmt.Sprintf("%s %q: response status code %d: %s", err.Method, err.URL, err.StatusCode, errmsg)
|
||||
}
|
||||
|
||||
// Unwrap returns the internal errors of err if any.
|
||||
func (err *ErrorResponse) Unwrap() error {
|
||||
if len(err.Errors) == 0 {
|
||||
return nil
|
||||
}
|
||||
return err.Errors
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
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 errutil
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
|
||||
"oras.land/oras-go/v2/registry/remote/errcode"
|
||||
)
|
||||
|
||||
// maxErrorBytes specifies the default limit on how many response bytes are
|
||||
// allowed in the server's error response.
|
||||
// A typical error message is around 200 bytes. Hence, 8 KiB should be
|
||||
// sufficient.
|
||||
const maxErrorBytes int64 = 8 * 1024 // 8 KiB
|
||||
|
||||
// ParseErrorResponse parses the error returned by the remote registry.
|
||||
func ParseErrorResponse(resp *http.Response) error {
|
||||
resultErr := &errcode.ErrorResponse{
|
||||
Method: resp.Request.Method,
|
||||
URL: resp.Request.URL,
|
||||
StatusCode: resp.StatusCode,
|
||||
}
|
||||
var body struct {
|
||||
Errors errcode.Errors `json:"errors"`
|
||||
}
|
||||
lr := io.LimitReader(resp.Body, maxErrorBytes)
|
||||
if err := json.NewDecoder(lr).Decode(&body); err == nil {
|
||||
resultErr.Errors = body.Errors
|
||||
}
|
||||
return resultErr
|
||||
}
|
||||
|
||||
// IsErrorCode returns true if err is an Error and its Code equals to code.
|
||||
func IsErrorCode(err error, code string) bool {
|
||||
var ec errcode.Error
|
||||
return errors.As(err, &ec) && ec.Code == code
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
/*
|
||||
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 remote
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/internal/docker"
|
||||
"oras.land/oras-go/v2/internal/spec"
|
||||
)
|
||||
|
||||
// defaultManifestMediaTypes contains the default set of manifests media types.
|
||||
var defaultManifestMediaTypes = []string{
|
||||
docker.MediaTypeManifest,
|
||||
docker.MediaTypeManifestList,
|
||||
ocispec.MediaTypeImageManifest,
|
||||
ocispec.MediaTypeImageIndex,
|
||||
spec.MediaTypeArtifactManifest,
|
||||
}
|
||||
|
||||
// defaultManifestAcceptHeader is the default set in the `Accept` header for
|
||||
// resolving manifests from tags.
|
||||
var defaultManifestAcceptHeader = strings.Join(defaultManifestMediaTypes, ", ")
|
||||
|
||||
// isManifest determines if the given descriptor points to a manifest.
|
||||
func isManifest(manifestMediaTypes []string, desc ocispec.Descriptor) bool {
|
||||
if len(manifestMediaTypes) == 0 {
|
||||
manifestMediaTypes = defaultManifestMediaTypes
|
||||
}
|
||||
for _, mediaType := range manifestMediaTypes {
|
||||
if desc.MediaType == mediaType {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// manifestAcceptHeader generates the set in the `Accept` header for resolving
|
||||
// manifests from tags.
|
||||
func manifestAcceptHeader(manifestMediaTypes []string) string {
|
||||
if len(manifestMediaTypes) == 0 {
|
||||
return defaultManifestAcceptHeader
|
||||
}
|
||||
return strings.Join(manifestMediaTypes, ", ")
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/*
|
||||
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 remote
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/internal/descriptor"
|
||||
)
|
||||
|
||||
// zeroDigest represents a digest that consists of zeros. zeroDigest is used
|
||||
// for pinging Referrers API.
|
||||
const zeroDigest = "sha256:0000000000000000000000000000000000000000000000000000000000000000"
|
||||
|
||||
// referrersState represents the state of Referrers API.
|
||||
type referrersState = int32
|
||||
|
||||
const (
|
||||
// referrersStateUnknown represents an unknown state of Referrers API.
|
||||
referrersStateUnknown referrersState = iota
|
||||
// referrersStateSupported represents that the repository is known to
|
||||
// support Referrers API.
|
||||
referrersStateSupported
|
||||
// referrersStateUnsupported represents that the repository is known to
|
||||
// not support Referrers API.
|
||||
referrersStateUnsupported
|
||||
)
|
||||
|
||||
// referrerOperation represents an operation on a referrer.
|
||||
type referrerOperation = int32
|
||||
|
||||
const (
|
||||
// referrerOperationAdd represents an addition operation on a referrer.
|
||||
referrerOperationAdd referrerOperation = iota
|
||||
// referrerOperationRemove represents a removal operation on a referrer.
|
||||
referrerOperationRemove
|
||||
)
|
||||
|
||||
// referrerChange represents a change on a referrer.
|
||||
type referrerChange struct {
|
||||
referrer ocispec.Descriptor
|
||||
operation referrerOperation
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrReferrersCapabilityAlreadySet is returned by SetReferrersCapability()
|
||||
// when the Referrers API capability has been already set.
|
||||
ErrReferrersCapabilityAlreadySet = errors.New("referrers capability cannot be changed once set")
|
||||
|
||||
// errNoReferrerUpdate is returned by applyReferrerChanges() when there
|
||||
// is no any referrer update.
|
||||
errNoReferrerUpdate = errors.New("no referrer update")
|
||||
)
|
||||
|
||||
const (
|
||||
// opDeleteReferrersIndex represents the operation for deleting a
|
||||
// referrers index.
|
||||
opDeleteReferrersIndex = "DeleteReferrersIndex"
|
||||
)
|
||||
|
||||
// ReferrersError records an error and the operation and the subject descriptor.
|
||||
type ReferrersError struct {
|
||||
// Op represents the failing operation.
|
||||
Op string
|
||||
// Subject is the descriptor of referenced artifact.
|
||||
Subject ocispec.Descriptor
|
||||
// Err is the entity of referrers error.
|
||||
Err error
|
||||
}
|
||||
|
||||
// Error returns error msg of IgnorableError.
|
||||
func (e *ReferrersError) Error() string {
|
||||
return e.Err.Error()
|
||||
}
|
||||
|
||||
// Unwrap returns the inner error of IgnorableError.
|
||||
func (e *ReferrersError) Unwrap() error {
|
||||
return errors.Unwrap(e.Err)
|
||||
}
|
||||
|
||||
// IsIndexDelete tells if e is kind of error related to referrers
|
||||
// index deletion.
|
||||
func (e *ReferrersError) IsReferrersIndexDelete() bool {
|
||||
return e.Op == opDeleteReferrersIndex
|
||||
}
|
||||
|
||||
// buildReferrersTag builds the referrers tag for the given manifest descriptor.
|
||||
// Format: <algorithm>-<digest>
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#unavailable-referrers-api
|
||||
func buildReferrersTag(desc ocispec.Descriptor) (string, error) {
|
||||
if err := desc.Digest.Validate(); err != nil {
|
||||
return "", fmt.Errorf("failed to build referrers tag for %s: %w", desc.Digest, err)
|
||||
}
|
||||
alg := desc.Digest.Algorithm().String()
|
||||
encoded := desc.Digest.Encoded()
|
||||
return alg + "-" + encoded, nil
|
||||
}
|
||||
|
||||
// isReferrersFilterApplied checks if requsted is in the applied filter list.
|
||||
func isReferrersFilterApplied(applied, requested string) bool {
|
||||
if applied == "" || requested == "" {
|
||||
return false
|
||||
}
|
||||
filters := strings.Split(applied, ",")
|
||||
for _, f := range filters {
|
||||
if f == requested {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// filterReferrers filters a slice of referrers by artifactType in place.
|
||||
// The returned slice contains matching referrers.
|
||||
func filterReferrers(refs []ocispec.Descriptor, artifactType string) []ocispec.Descriptor {
|
||||
if artifactType == "" {
|
||||
return refs
|
||||
}
|
||||
var j int
|
||||
for i, ref := range refs {
|
||||
if ref.ArtifactType == artifactType {
|
||||
if i != j {
|
||||
refs[j] = ref
|
||||
}
|
||||
j++
|
||||
}
|
||||
}
|
||||
return refs[:j]
|
||||
}
|
||||
|
||||
// applyReferrerChanges applies referrerChanges on referrers and returns the
|
||||
// updated referrers.
|
||||
// Returns errNoReferrerUpdate if there is no any referrers updates.
|
||||
func applyReferrerChanges(referrers []ocispec.Descriptor, referrerChanges []referrerChange) ([]ocispec.Descriptor, error) {
|
||||
referrersMap := make(map[descriptor.Descriptor]int, len(referrers)+len(referrerChanges))
|
||||
updatedReferrers := make([]ocispec.Descriptor, 0, len(referrers)+len(referrerChanges))
|
||||
var updateRequired bool
|
||||
for _, r := range referrers {
|
||||
if content.Equal(r, ocispec.Descriptor{}) {
|
||||
// skip bad entry
|
||||
updateRequired = true
|
||||
continue
|
||||
}
|
||||
key := descriptor.FromOCI(r)
|
||||
if _, ok := referrersMap[key]; ok {
|
||||
// skip duplicates
|
||||
updateRequired = true
|
||||
continue
|
||||
}
|
||||
updatedReferrers = append(updatedReferrers, r)
|
||||
referrersMap[key] = len(updatedReferrers) - 1
|
||||
}
|
||||
|
||||
// apply changes
|
||||
for _, change := range referrerChanges {
|
||||
key := descriptor.FromOCI(change.referrer)
|
||||
switch change.operation {
|
||||
case referrerOperationAdd:
|
||||
if _, ok := referrersMap[key]; !ok {
|
||||
// add distinct referrers
|
||||
updatedReferrers = append(updatedReferrers, change.referrer)
|
||||
referrersMap[key] = len(updatedReferrers) - 1
|
||||
}
|
||||
case referrerOperationRemove:
|
||||
if pos, ok := referrersMap[key]; ok {
|
||||
// remove referrers that are already in the map
|
||||
updatedReferrers[pos] = ocispec.Descriptor{}
|
||||
delete(referrersMap, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// skip unnecessary update
|
||||
if !updateRequired && len(referrersMap) == len(referrers) {
|
||||
// if the result referrer map contains the same content as the
|
||||
// original referrers, consider that there is no update on the
|
||||
// referrers.
|
||||
for _, r := range referrers {
|
||||
key := descriptor.FromOCI(r)
|
||||
if _, ok := referrersMap[key]; !ok {
|
||||
updateRequired = true
|
||||
}
|
||||
}
|
||||
if !updateRequired {
|
||||
return nil, errNoReferrerUpdate
|
||||
}
|
||||
}
|
||||
|
||||
return removeEmptyDescriptors(updatedReferrers, len(referrersMap)), nil
|
||||
}
|
||||
|
||||
// removeEmptyDescriptors in-place removes empty items from descs, given a hint
|
||||
// of the number of non-empty descriptors.
|
||||
func removeEmptyDescriptors(descs []ocispec.Descriptor, hint int) []ocispec.Descriptor {
|
||||
j := 0
|
||||
for i, r := range descs {
|
||||
if !content.Equal(r, ocispec.Descriptor{}) {
|
||||
if i > j {
|
||||
descs[j] = r
|
||||
}
|
||||
j++
|
||||
}
|
||||
if j == hint {
|
||||
break
|
||||
}
|
||||
}
|
||||
return descs[:j]
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
/*
|
||||
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 remote provides a client to the remote registry.
|
||||
// Reference: https://github.com/distribution/distribution
|
||||
package remote
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
"oras.land/oras-go/v2/registry/remote/auth"
|
||||
"oras.land/oras-go/v2/registry/remote/internal/errutil"
|
||||
)
|
||||
|
||||
// RepositoryOptions is an alias of Repository to avoid name conflicts.
|
||||
// It also hides all methods associated with Repository.
|
||||
type RepositoryOptions Repository
|
||||
|
||||
// Registry is an HTTP client to a remote registry.
|
||||
type Registry struct {
|
||||
// RepositoryOptions contains common options for Registry and Repository.
|
||||
// It is also used as a template for derived repositories.
|
||||
RepositoryOptions
|
||||
|
||||
// RepositoryListPageSize specifies the page size when invoking the catalog
|
||||
// API.
|
||||
// If zero, the page size is determined by the remote registry.
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#catalog
|
||||
RepositoryListPageSize int
|
||||
}
|
||||
|
||||
// NewRegistry creates a client to the remote registry with the specified domain
|
||||
// name.
|
||||
// Example: localhost:5000
|
||||
func NewRegistry(name string) (*Registry, error) {
|
||||
ref := registry.Reference{
|
||||
Registry: name,
|
||||
}
|
||||
if err := ref.ValidateRegistry(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Registry{
|
||||
RepositoryOptions: RepositoryOptions{
|
||||
Reference: ref,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// client returns an HTTP client used to access the remote registry.
|
||||
// A default HTTP client is return if the client is not configured.
|
||||
func (r *Registry) client() Client {
|
||||
if r.Client == nil {
|
||||
return auth.DefaultClient
|
||||
}
|
||||
return r.Client
|
||||
}
|
||||
|
||||
// do sends an HTTP request and returns an HTTP response using the HTTP client
|
||||
// returned by r.client().
|
||||
func (r *Registry) do(req *http.Request) (*http.Response, error) {
|
||||
if r.HandleWarning == nil {
|
||||
return r.client().Do(req)
|
||||
}
|
||||
|
||||
resp, err := r.client().Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
handleWarningHeaders(resp.Header.Values(headerWarning), r.HandleWarning)
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// Ping checks whether or not the registry implement Docker Registry API V2 or
|
||||
// OCI Distribution Specification.
|
||||
// Ping can be used to check authentication when an auth client is configured.
|
||||
//
|
||||
// References:
|
||||
// - https://distribution.github.io/distribution/spec/api/#base
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#api
|
||||
func (r *Registry) Ping(ctx context.Context) error {
|
||||
url := buildRegistryBaseURL(r.PlainHTTP, r.Reference)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
resp, err := r.do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return nil
|
||||
case http.StatusNotFound:
|
||||
return errdef.ErrNotFound
|
||||
default:
|
||||
return errutil.ParseErrorResponse(resp)
|
||||
}
|
||||
}
|
||||
|
||||
// Repositories lists the name of repositories available in the registry.
|
||||
// See also `RepositoryListPageSize`.
|
||||
//
|
||||
// If `last` is NOT empty, the entries in the response start after the
|
||||
// repo specified by `last`. Otherwise, the response starts from the top
|
||||
// of the Repositories list.
|
||||
//
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#catalog
|
||||
func (r *Registry) Repositories(ctx context.Context, last string, fn func(repos []string) error) error {
|
||||
ctx = auth.AppendScopesForHost(ctx, r.Reference.Host(), auth.ScopeRegistryCatalog)
|
||||
url := buildRegistryCatalogURL(r.PlainHTTP, r.Reference)
|
||||
var err error
|
||||
for err == nil {
|
||||
url, err = r.repositories(ctx, last, fn, url)
|
||||
// clear `last` for subsequent pages
|
||||
last = ""
|
||||
}
|
||||
if err != errNoLink {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// repositories returns a single page of repository list with the next link.
|
||||
func (r *Registry) repositories(ctx context.Context, last string, fn func(repos []string) error, url string) (string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if r.RepositoryListPageSize > 0 || last != "" {
|
||||
q := req.URL.Query()
|
||||
if r.RepositoryListPageSize > 0 {
|
||||
q.Set("n", strconv.Itoa(r.RepositoryListPageSize))
|
||||
}
|
||||
if last != "" {
|
||||
q.Set("last", last)
|
||||
}
|
||||
req.URL.RawQuery = q.Encode()
|
||||
}
|
||||
resp, err := r.do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", errutil.ParseErrorResponse(resp)
|
||||
}
|
||||
var page struct {
|
||||
Repositories []string `json:"repositories"`
|
||||
}
|
||||
lr := limitReader(resp.Body, r.MaxMetadataBytes)
|
||||
if err := json.NewDecoder(lr).Decode(&page); err != nil {
|
||||
return "", fmt.Errorf("%s %q: failed to decode response: %w", resp.Request.Method, resp.Request.URL, err)
|
||||
}
|
||||
if err := fn(page.Repositories); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return parseLink(resp)
|
||||
}
|
||||
|
||||
// Repository returns a repository reference by the given name.
|
||||
func (r *Registry) Repository(ctx context.Context, name string) (registry.Repository, error) {
|
||||
ref := registry.Reference{
|
||||
Registry: r.Reference.Registry,
|
||||
Repository: name,
|
||||
}
|
||||
return newRepositoryWithOptions(ref, &r.RepositoryOptions)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
/*
|
||||
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 retry
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// DefaultClient is a client with the default retry policy.
|
||||
var DefaultClient = NewClient()
|
||||
|
||||
// NewClient creates an HTTP client with the default retry policy.
|
||||
func NewClient() *http.Client {
|
||||
return &http.Client{
|
||||
Transport: NewTransport(nil),
|
||||
}
|
||||
}
|
||||
|
||||
// Transport is an HTTP transport with retry policy.
|
||||
type Transport struct {
|
||||
// Base is the underlying HTTP transport to use.
|
||||
// If nil, http.DefaultTransport is used for round trips.
|
||||
Base http.RoundTripper
|
||||
|
||||
// Policy returns a retry Policy to use for the request.
|
||||
// If nil, DefaultPolicy is used to determine if the request should be retried.
|
||||
Policy func() Policy
|
||||
}
|
||||
|
||||
// NewTransport creates an HTTP Transport with the default retry policy.
|
||||
func NewTransport(base http.RoundTripper) *Transport {
|
||||
return &Transport{
|
||||
Base: base,
|
||||
}
|
||||
}
|
||||
|
||||
// RoundTrip executes a single HTTP transaction, returning a Response for the
|
||||
// provided Request.
|
||||
// It relies on the configured Policy to determine if the request should be
|
||||
// retried and to backoff.
|
||||
func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
ctx := req.Context()
|
||||
policy := t.policy()
|
||||
attempt := 0
|
||||
for {
|
||||
resp, respErr := t.roundTrip(req)
|
||||
duration, err := policy.Retry(attempt, resp, respErr)
|
||||
if err != nil {
|
||||
if respErr == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if duration < 0 {
|
||||
return resp, respErr
|
||||
}
|
||||
|
||||
// rewind the body if possible
|
||||
if req.Body != nil {
|
||||
if req.GetBody == nil {
|
||||
// body can't be rewound, so we can't retry
|
||||
return resp, respErr
|
||||
}
|
||||
body, err := req.GetBody()
|
||||
if err != nil {
|
||||
// failed to rewind the body, so we can't retry
|
||||
return resp, respErr
|
||||
}
|
||||
req.Body = body
|
||||
}
|
||||
|
||||
// close the response body if needed
|
||||
if respErr == nil {
|
||||
resp.Body.Close()
|
||||
}
|
||||
|
||||
timer := time.NewTimer(duration)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
timer.Stop()
|
||||
return nil, ctx.Err()
|
||||
case <-timer.C:
|
||||
}
|
||||
attempt++
|
||||
}
|
||||
}
|
||||
|
||||
func (t *Transport) roundTrip(req *http.Request) (*http.Response, error) {
|
||||
if t.Base == nil {
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
}
|
||||
return t.Base.RoundTrip(req)
|
||||
}
|
||||
|
||||
func (t *Transport) policy() Policy {
|
||||
if t.Policy == nil {
|
||||
return DefaultPolicy
|
||||
}
|
||||
return t.Policy()
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
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 retry
|
||||
|
||||
import (
|
||||
"hash/maphash"
|
||||
"math"
|
||||
"math/rand/v2"
|
||||
"net"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"time"
|
||||
)
|
||||
|
||||
// headerRetryAfter is the header key for Retry-After.
|
||||
const headerRetryAfter = "Retry-After"
|
||||
|
||||
// DefaultPolicy is a policy with fine-tuned retry parameters.
|
||||
// It uses an exponential backoff with jitter.
|
||||
var DefaultPolicy Policy = &GenericPolicy{
|
||||
Retryable: DefaultPredicate,
|
||||
Backoff: DefaultBackoff,
|
||||
MinWait: 200 * time.Millisecond,
|
||||
MaxWait: 3 * time.Second,
|
||||
MaxRetry: 5,
|
||||
}
|
||||
|
||||
// DefaultPredicate is a predicate that retries on 5xx errors, 429 Too Many
|
||||
// Requests, 408 Request Timeout and on network dial timeout.
|
||||
var DefaultPredicate Predicate = func(resp *http.Response, err error) (bool, error) {
|
||||
if err != nil {
|
||||
// retry on Dial timeout
|
||||
if err, ok := err.(net.Error); ok && err.Timeout() {
|
||||
return true, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
|
||||
if resp.StatusCode == http.StatusRequestTimeout || resp.StatusCode == http.StatusTooManyRequests {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
if resp.StatusCode == 0 || resp.StatusCode >= 500 {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// DefaultBackoff is a backoff that uses an exponential backoff with jitter.
|
||||
// It uses a base of 250ms, a factor of 2 and a jitter of 10%.
|
||||
var DefaultBackoff Backoff = ExponentialBackoff(250*time.Millisecond, 2, 0.1)
|
||||
|
||||
// Policy is a retry policy.
|
||||
type Policy interface {
|
||||
// Retry returns the duration to wait before retrying the request.
|
||||
// It returns a negative value if the request should not be retried.
|
||||
// The attempt is used to:
|
||||
// - calculate the backoff duration, the default backoff is an exponential backoff.
|
||||
// - determine if the request should be retried.
|
||||
// The attempt starts at 0 and should be less than MaxRetry for the request to
|
||||
// be retried.
|
||||
Retry(attempt int, resp *http.Response, err error) (time.Duration, error)
|
||||
}
|
||||
|
||||
// Predicate is a function that returns true if the request should be retried.
|
||||
type Predicate func(resp *http.Response, err error) (bool, error)
|
||||
|
||||
// Backoff is a function that returns the duration to wait before retrying the
|
||||
// request. The attempt, is the next attempt number. The response is the
|
||||
// response from the previous request.
|
||||
type Backoff func(attempt int, resp *http.Response) time.Duration
|
||||
|
||||
// ExponentialBackoff returns a Backoff that uses an exponential backoff with
|
||||
// jitter. The backoff is calculated as:
|
||||
//
|
||||
// temp = backoff * factor ^ attempt
|
||||
// interval = temp * (1 - jitter) + rand.Int64N(2 * jitter * temp)
|
||||
//
|
||||
// The HTTP response is checked for a Retry-After header. If it is present, the
|
||||
// value is used as the backoff duration.
|
||||
func ExponentialBackoff(backoff time.Duration, factor, jitter float64) Backoff {
|
||||
return func(attempt int, resp *http.Response) time.Duration {
|
||||
var h maphash.Hash
|
||||
h.SetSeed(maphash.MakeSeed())
|
||||
rand := rand.New(rand.NewPCG(0, h.Sum64()))
|
||||
|
||||
// check Retry-After
|
||||
if resp != nil && resp.StatusCode == http.StatusTooManyRequests {
|
||||
if v := resp.Header.Get(headerRetryAfter); v != "" {
|
||||
if retryAfter, _ := strconv.ParseInt(v, 10, 64); retryAfter > 0 {
|
||||
return time.Duration(retryAfter) * time.Second
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// do exponential backoff with jitter
|
||||
temp := float64(backoff) * math.Pow(factor, float64(attempt))
|
||||
return time.Duration(temp*(1-jitter)) + time.Duration(rand.Int64N(int64(2*jitter*temp)))
|
||||
}
|
||||
}
|
||||
|
||||
// GenericPolicy is a generic retry policy.
|
||||
type GenericPolicy struct {
|
||||
// Retryable is a predicate that returns true if the request should be
|
||||
// retried.
|
||||
Retryable Predicate
|
||||
|
||||
// Backoff is a function that returns the duration to wait before retrying.
|
||||
Backoff Backoff
|
||||
|
||||
// MinWait is the minimum duration to wait before retrying.
|
||||
MinWait time.Duration
|
||||
|
||||
// MaxWait is the maximum duration to wait before retrying.
|
||||
MaxWait time.Duration
|
||||
|
||||
// MaxRetry is the maximum number of retries.
|
||||
MaxRetry int
|
||||
}
|
||||
|
||||
// Retry returns the duration to wait before retrying the request.
|
||||
// It returns -1 if the request should not be retried.
|
||||
func (p *GenericPolicy) Retry(attempt int, resp *http.Response, err error) (time.Duration, error) {
|
||||
if attempt >= p.MaxRetry {
|
||||
return -1, nil
|
||||
}
|
||||
if ok, err := p.Retryable(resp, err); err != nil {
|
||||
return -1, err
|
||||
} else if !ok {
|
||||
return -1, nil
|
||||
}
|
||||
backoff := p.Backoff(attempt, resp)
|
||||
if backoff < p.MinWait {
|
||||
backoff = p.MinWait
|
||||
}
|
||||
if backoff > p.MaxWait {
|
||||
backoff = p.MaxWait
|
||||
}
|
||||
return backoff, nil
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
/*
|
||||
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 remote
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/opencontainers/go-digest"
|
||||
"oras.land/oras-go/v2/registry"
|
||||
)
|
||||
|
||||
// buildScheme returns HTTP scheme used to access the remote registry.
|
||||
func buildScheme(plainHTTP bool) string {
|
||||
if plainHTTP {
|
||||
return "http"
|
||||
}
|
||||
return "https"
|
||||
}
|
||||
|
||||
// buildRegistryBaseURL builds the URL for accessing the base API.
|
||||
// Format: <scheme>://<registry>/v2/
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#base
|
||||
func buildRegistryBaseURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return fmt.Sprintf("%s://%s/v2/", buildScheme(plainHTTP), ref.Host())
|
||||
}
|
||||
|
||||
// buildRegistryCatalogURL builds the URL for accessing the catalog API.
|
||||
// Format: <scheme>://<registry>/v2/_catalog
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#catalog
|
||||
func buildRegistryCatalogURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return fmt.Sprintf("%s://%s/v2/_catalog", buildScheme(plainHTTP), ref.Host())
|
||||
}
|
||||
|
||||
// buildRepositoryBaseURL builds the base endpoint of the remote repository.
|
||||
// Format: <scheme>://<registry>/v2/<repository>
|
||||
func buildRepositoryBaseURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return fmt.Sprintf("%s://%s/v2/%s", buildScheme(plainHTTP), ref.Host(), ref.Repository)
|
||||
}
|
||||
|
||||
// buildRepositoryTagListURL builds the URL for accessing the tag list API.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/tags/list
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#tags
|
||||
func buildRepositoryTagListURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return buildRepositoryBaseURL(plainHTTP, ref) + "/tags/list"
|
||||
}
|
||||
|
||||
// buildRepositoryManifestURL builds the URL for accessing the manifest API.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/manifests/<digest_or_tag>
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#manifest
|
||||
func buildRepositoryManifestURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return strings.Join([]string{
|
||||
buildRepositoryBaseURL(plainHTTP, ref),
|
||||
"manifests",
|
||||
ref.Reference,
|
||||
}, "/")
|
||||
}
|
||||
|
||||
// buildRepositoryBlobURL builds the URL for accessing the blob API.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/blobs/<digest>
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#blob
|
||||
func buildRepositoryBlobURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return strings.Join([]string{
|
||||
buildRepositoryBaseURL(plainHTTP, ref),
|
||||
"blobs",
|
||||
ref.Reference,
|
||||
}, "/")
|
||||
}
|
||||
|
||||
// buildRepositoryBlobUploadURL builds the URL for blob uploading.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/blobs/uploads/
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#initiate-blob-upload
|
||||
func buildRepositoryBlobUploadURL(plainHTTP bool, ref registry.Reference) string {
|
||||
return buildRepositoryBaseURL(plainHTTP, ref) + "/blobs/uploads/"
|
||||
}
|
||||
|
||||
// buildRepositoryBlobMountURLbuilds the URL for cross-repository mounting.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/blobs/uploads/?mount=<digest>&from=<other_repository>
|
||||
// Reference: https://distribution.github.io/distribution/spec/api/#blob
|
||||
func buildRepositoryBlobMountURL(plainHTTP bool, ref registry.Reference, d digest.Digest, fromRepo string) string {
|
||||
return fmt.Sprintf("%s?mount=%s&from=%s",
|
||||
buildRepositoryBlobUploadURL(plainHTTP, ref),
|
||||
d,
|
||||
fromRepo,
|
||||
)
|
||||
}
|
||||
|
||||
// buildReferrersURL builds the URL for querying the Referrers API.
|
||||
// Format: <scheme>://<registry>/v2/<repository>/referrers/<digest>?artifactType=<artifactType>
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers
|
||||
func buildReferrersURL(plainHTTP bool, ref registry.Reference, artifactType string) string {
|
||||
var query string
|
||||
if artifactType != "" {
|
||||
v := url.Values{}
|
||||
v.Set("artifactType", artifactType)
|
||||
query = "?" + v.Encode()
|
||||
}
|
||||
|
||||
return fmt.Sprintf(
|
||||
"%s/referrers/%s%s",
|
||||
buildRepositoryBaseURL(plainHTTP, ref),
|
||||
ref.Reference,
|
||||
query,
|
||||
)
|
||||
}
|
||||
@@ -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 remote
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
)
|
||||
|
||||
// defaultMaxMetadataBytes specifies the default limit on how many response
|
||||
// bytes are allowed in the server's response to the metadata APIs.
|
||||
// See also: Repository.MaxMetadataBytes
|
||||
var defaultMaxMetadataBytes int64 = 4 * 1024 * 1024 // 4 MiB
|
||||
|
||||
// errNoLink is returned by parseLink() when no Link header is present.
|
||||
var errNoLink = errors.New("no Link header in response")
|
||||
|
||||
// parseLink returns the URL of the response's "Link" header, if present.
|
||||
func parseLink(resp *http.Response) (string, error) {
|
||||
link := resp.Header.Get("Link")
|
||||
if link == "" {
|
||||
return "", errNoLink
|
||||
}
|
||||
if link[0] != '<' {
|
||||
return "", fmt.Errorf("invalid next link %q: missing '<'", link)
|
||||
}
|
||||
if i := strings.IndexByte(link, '>'); i == -1 {
|
||||
return "", fmt.Errorf("invalid next link %q: missing '>'", link)
|
||||
} else {
|
||||
link = link[1:i]
|
||||
}
|
||||
|
||||
linkURL, err := resp.Request.URL.Parse(link)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return linkURL.String(), nil
|
||||
}
|
||||
|
||||
// limitReader returns a Reader that reads from r but stops with EOF after n
|
||||
// bytes. If n is less than or equal to zero, defaultMaxMetadataBytes is used.
|
||||
func limitReader(r io.Reader, n int64) io.Reader {
|
||||
if n <= 0 {
|
||||
n = defaultMaxMetadataBytes
|
||||
}
|
||||
return io.LimitReader(r, n)
|
||||
}
|
||||
|
||||
// limitSize returns ErrSizeExceedsLimit if the size of desc exceeds the limit n.
|
||||
// If n is less than or equal to zero, defaultMaxMetadataBytes is used.
|
||||
func limitSize(desc ocispec.Descriptor, n int64) error {
|
||||
if n <= 0 {
|
||||
n = defaultMaxMetadataBytes
|
||||
}
|
||||
if desc.Size > n {
|
||||
return fmt.Errorf(
|
||||
"content size %v exceeds MaxMetadataBytes %v: %w",
|
||||
desc.Size,
|
||||
n,
|
||||
errdef.ErrSizeExceedsLimit)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// decodeJSON safely reads the JSON content described by desc, and
|
||||
// decodes it into v.
|
||||
func decodeJSON(r io.Reader, desc ocispec.Descriptor, v any) error {
|
||||
jsonBytes, err := content.ReadAll(r, desc)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(jsonBytes, v)
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
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 remote
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const (
|
||||
// headerWarning is the "Warning" header.
|
||||
// Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5
|
||||
headerWarning = "Warning"
|
||||
|
||||
// warnCode299 is the 299 warn-code.
|
||||
// Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5
|
||||
warnCode299 = 299
|
||||
|
||||
// warnAgentUnknown represents an unknown warn-agent.
|
||||
// Reference: https://www.rfc-editor.org/rfc/rfc7234#section-5.5
|
||||
warnAgentUnknown = "-"
|
||||
)
|
||||
|
||||
// errUnexpectedWarningFormat is returned by parseWarningHeader when
|
||||
// an unexpected warning format is encountered.
|
||||
var errUnexpectedWarningFormat = errors.New("unexpected warning format")
|
||||
|
||||
// WarningValue represents the value of the Warning header.
|
||||
//
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#warnings
|
||||
// - https://www.rfc-editor.org/rfc/rfc7234#section-5.5
|
||||
type WarningValue struct {
|
||||
// Code is the warn-code.
|
||||
Code int
|
||||
// Agent is the warn-agent.
|
||||
Agent string
|
||||
// Text is the warn-text.
|
||||
Text string
|
||||
}
|
||||
|
||||
// Warning contains the value of the warning header and may contain
|
||||
// other information related to the warning.
|
||||
//
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#warnings
|
||||
// - https://www.rfc-editor.org/rfc/rfc7234#section-5.5
|
||||
type Warning struct {
|
||||
// WarningValue is the value of the warning header.
|
||||
WarningValue
|
||||
}
|
||||
|
||||
// parseWarningHeader parses the warning header into WarningValue.
|
||||
func parseWarningHeader(header string) (WarningValue, error) {
|
||||
if len(header) < 9 || !strings.HasPrefix(header, `299 - "`) || !strings.HasSuffix(header, `"`) {
|
||||
// minimum header value: `299 - "x"`
|
||||
return WarningValue{}, fmt.Errorf("%s: %w", header, errUnexpectedWarningFormat)
|
||||
}
|
||||
|
||||
// validate text only as code and agent are fixed
|
||||
quotedText := header[6:] // behind `299 - `, quoted by "
|
||||
text, err := strconv.Unquote(quotedText)
|
||||
if err != nil {
|
||||
return WarningValue{}, fmt.Errorf("%s: unexpected text: %w: %v", header, errUnexpectedWarningFormat, err)
|
||||
}
|
||||
|
||||
return WarningValue{
|
||||
Code: warnCode299,
|
||||
Agent: warnAgentUnknown,
|
||||
Text: text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// handleWarningHeaders parses the warning headers and handles the parsed
|
||||
// warnings using handleWarning.
|
||||
func handleWarningHeaders(headers []string, handleWarning func(Warning)) {
|
||||
for _, h := range headers {
|
||||
if value, err := parseWarningHeader(h); err == nil {
|
||||
// ignore warnings in unexpected formats
|
||||
handleWarning(Warning{
|
||||
WarningValue: value,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
+226
@@ -0,0 +1,226 @@
|
||||
/*
|
||||
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 registry
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
|
||||
ocispec "github.com/opencontainers/image-spec/specs-go/v1"
|
||||
"oras.land/oras-go/v2/content"
|
||||
"oras.land/oras-go/v2/errdef"
|
||||
"oras.land/oras-go/v2/internal/descriptor"
|
||||
"oras.land/oras-go/v2/internal/spec"
|
||||
)
|
||||
|
||||
// Repository is an ORAS target and an union of the blob and the manifest CASs.
|
||||
//
|
||||
// As specified by https://distribution.github.io/distribution/spec/api/, it is natural to
|
||||
// assume that content.Resolver interface only works for manifests. Tagging a
|
||||
// blob may be resulted in an `ErrUnsupported` error. However, this interface
|
||||
// does not restrict tagging blobs.
|
||||
//
|
||||
// Since a repository is an union of the blob and the manifest CASs, all
|
||||
// operations defined in the `BlobStore` are executed depending on the media
|
||||
// type of the given descriptor accordingly.
|
||||
//
|
||||
// Furthermore, this interface also provides the ability to enforce the
|
||||
// separation of the blob and the manifests CASs.
|
||||
type Repository interface {
|
||||
content.Storage
|
||||
content.Deleter
|
||||
content.TagResolver
|
||||
ReferenceFetcher
|
||||
ReferencePusher
|
||||
ReferrerLister
|
||||
TagLister
|
||||
|
||||
// Blobs provides access to the blob CAS only, which contains config blobs,
|
||||
// layers, and other generic blobs.
|
||||
Blobs() BlobStore
|
||||
|
||||
// Manifests provides access to the manifest CAS only.
|
||||
Manifests() ManifestStore
|
||||
}
|
||||
|
||||
// BlobStore is a CAS with the ability to stat and delete its content.
|
||||
type BlobStore interface {
|
||||
content.Storage
|
||||
content.Deleter
|
||||
content.Resolver
|
||||
ReferenceFetcher
|
||||
}
|
||||
|
||||
// ManifestStore is a CAS with the ability to stat and delete its content.
|
||||
// Besides, ManifestStore provides reference tagging.
|
||||
type ManifestStore interface {
|
||||
BlobStore
|
||||
content.Tagger
|
||||
ReferencePusher
|
||||
}
|
||||
|
||||
// ReferencePusher provides advanced push with the tag service.
|
||||
type ReferencePusher interface {
|
||||
// PushReference pushes the manifest with a reference tag.
|
||||
PushReference(ctx context.Context, expected ocispec.Descriptor, content io.Reader, reference string) error
|
||||
}
|
||||
|
||||
// ReferenceFetcher provides advanced fetch with the tag service.
|
||||
type ReferenceFetcher interface {
|
||||
// FetchReference fetches the content identified by the reference.
|
||||
FetchReference(ctx context.Context, reference string) (ocispec.Descriptor, io.ReadCloser, error)
|
||||
}
|
||||
|
||||
// ReferrerLister provides the Referrers API.
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers
|
||||
type ReferrerLister interface {
|
||||
Referrers(ctx context.Context, desc ocispec.Descriptor, artifactType string, fn func(referrers []ocispec.Descriptor) error) error
|
||||
}
|
||||
|
||||
// TagLister lists tags by the tag service.
|
||||
type TagLister interface {
|
||||
// Tags lists the tags available in the repository.
|
||||
// Since the returned tag list may be paginated by the underlying
|
||||
// implementation, a function should be passed in to process the paginated
|
||||
// tag list.
|
||||
//
|
||||
// `last` argument is the `last` parameter when invoking the tags API.
|
||||
// If `last` is NOT empty, the entries in the response start after the
|
||||
// tag specified by `last`. Otherwise, the response starts from the top
|
||||
// of the Tags list.
|
||||
//
|
||||
// Note: When implemented by a remote registry, the tags API is called.
|
||||
// However, not all registries supports pagination or conforms the
|
||||
// specification.
|
||||
//
|
||||
// References:
|
||||
// - https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#content-discovery
|
||||
// - https://distribution.github.io/distribution/spec/api/#tags
|
||||
// See also `Tags()` in this package.
|
||||
Tags(ctx context.Context, last string, fn func(tags []string) error) error
|
||||
}
|
||||
|
||||
// Mounter allows cross-repository blob mounts.
|
||||
// For backward compatibility reasons, this is not implemented by
|
||||
// BlobStore: use a type assertion to check availability.
|
||||
type Mounter interface {
|
||||
// Mount makes the blob with the given descriptor in fromRepo
|
||||
// available in the repository signified by the receiver.
|
||||
Mount(ctx context.Context,
|
||||
desc ocispec.Descriptor,
|
||||
fromRepo string,
|
||||
getContent func() (io.ReadCloser, error),
|
||||
) error
|
||||
}
|
||||
|
||||
// Tags lists the tags available in the repository.
|
||||
func Tags(ctx context.Context, repo TagLister) ([]string, error) {
|
||||
var res []string
|
||||
if err := repo.Tags(ctx, "", func(tags []string) error {
|
||||
res = append(res, tags...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// Referrers lists the descriptors of image or artifact manifests directly
|
||||
// referencing the given manifest descriptor.
|
||||
//
|
||||
// Reference: https://github.com/opencontainers/distribution-spec/blob/v1.1.1/spec.md#listing-referrers
|
||||
func Referrers(ctx context.Context, store content.ReadOnlyGraphStorage, desc ocispec.Descriptor, artifactType string) ([]ocispec.Descriptor, error) {
|
||||
if !descriptor.IsManifest(desc) {
|
||||
return nil, fmt.Errorf("the descriptor %v is not a manifest: %w", desc, errdef.ErrUnsupported)
|
||||
}
|
||||
|
||||
var results []ocispec.Descriptor
|
||||
|
||||
// use the Referrer API if it is available
|
||||
if rf, ok := store.(ReferrerLister); ok {
|
||||
if err := rf.Referrers(ctx, desc, artifactType, func(referrers []ocispec.Descriptor) error {
|
||||
results = append(results, referrers...)
|
||||
return nil
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
predecessors, err := store.Predecessors(ctx, desc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, node := range predecessors {
|
||||
switch node.MediaType {
|
||||
case ocispec.MediaTypeImageManifest:
|
||||
fetched, err := content.FetchAll(ctx, store, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var manifest ocispec.Manifest
|
||||
if err := json.Unmarshal(fetched, &manifest); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if manifest.Subject == nil || !content.Equal(*manifest.Subject, desc) {
|
||||
continue
|
||||
}
|
||||
node.ArtifactType = manifest.ArtifactType
|
||||
if node.ArtifactType == "" {
|
||||
node.ArtifactType = manifest.Config.MediaType
|
||||
}
|
||||
node.Annotations = manifest.Annotations
|
||||
case ocispec.MediaTypeImageIndex:
|
||||
fetched, err := content.FetchAll(ctx, store, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var index ocispec.Index
|
||||
if err := json.Unmarshal(fetched, &index); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if index.Subject == nil || !content.Equal(*index.Subject, desc) {
|
||||
continue
|
||||
}
|
||||
node.ArtifactType = index.ArtifactType
|
||||
node.Annotations = index.Annotations
|
||||
case spec.MediaTypeArtifactManifest:
|
||||
fetched, err := content.FetchAll(ctx, store, node)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var artifact spec.Artifact
|
||||
if err := json.Unmarshal(fetched, &artifact); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if artifact.Subject == nil || !content.Equal(*artifact.Subject, desc) {
|
||||
continue
|
||||
}
|
||||
node.ArtifactType = artifact.ArtifactType
|
||||
node.Annotations = artifact.Annotations
|
||||
default:
|
||||
continue
|
||||
}
|
||||
if artifactType == "" || artifactType == node.ArtifactType {
|
||||
// the field artifactType in referrers descriptor is allowed to be empty
|
||||
// https://github.com/opencontainers/distribution-spec/issues/458
|
||||
results = append(results, node)
|
||||
}
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
Reference in New Issue
Block a user