feat(registry): oras-go v3 full config stack integration

Migrate helm.sh/helm/v4/pkg/registry from oras.land/oras-go/v2 to
github.com/oras-project/oras-go/v3 (v3.0.2-dev) and wire in the full
container ecosystem config stack:

- Mechanical v2 -> v3 API migration (credentials, auth client, login/logout)
- config.LoadConfigs(): Docker config.json, containers auth.json,
  registries.conf, certs.d, policy.json, registries.d
- newRepository() uses RegistryProperties + NewRepositoryWithProperties
  for mirror resolution, per-registry TLS, and CLI flag overrides
- policy.json enforcement via builder.PolicyEvaluator
- registries.d + signedBy signature verification
- LoadConfigsWithOptions override paths (ConfigOptions)
- Login path Location rewrite from registries.conf
- Namespaced authentication support for registry login
- Set [pull,push] scope when pushing to token-auth registries

Closes: https://github.com/helm/helm/pull/11771
Signed-off-by: Terry Howe <terrylhowe@gmail.com>
pull/32065/head
Terry Howe 3 months ago
parent 67946bb22e
commit afab6bbc68
No known key found for this signature in database

@ -28,6 +28,7 @@ require (
github.com/moby/term v0.5.2 github.com/moby/term v0.5.2
github.com/opencontainers/go-digest v1.0.0 github.com/opencontainers/go-digest v1.0.0
github.com/opencontainers/image-spec v1.1.1 github.com/opencontainers/image-spec v1.1.1
github.com/oras-project/oras-go/v3 v3.0.2-dev
github.com/rubenv/sql-migrate v1.8.1 github.com/rubenv/sql-migrate v1.8.1
github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 github.com/santhosh-tekuri/jsonschema/v6 v6.0.3
github.com/spf13/cobra v1.10.2 github.com/spf13/cobra v1.10.2
@ -47,7 +48,6 @@ require (
k8s.io/client-go v0.36.4 k8s.io/client-go v0.36.4
k8s.io/klog/v2 v2.140.0 k8s.io/klog/v2 v2.140.0
k8s.io/kubectl v0.36.4 k8s.io/kubectl v0.36.4
oras.land/oras-go/v2 v2.6.2
sigs.k8s.io/controller-runtime v0.24.1 sigs.k8s.io/controller-runtime v0.24.1
sigs.k8s.io/kustomize/kyaml v0.21.1 sigs.k8s.io/kustomize/kyaml v0.21.1
sigs.k8s.io/yaml v1.6.0 sigs.k8s.io/yaml v1.6.0

@ -241,6 +241,8 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/oras-project/oras-go/v3 v3.0.2-dev h1:FoSopdghExPGXRV+CvMjpXNPO74oZhquowB15OZL3l4=
github.com/oras-project/oras-go/v3 v3.0.2-dev/go.mod h1:bSbdCn421//d2i4+mATPCvbNcmpM+fI1G8no/mZBcD4=
github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI= github.com/peterbourgon/diskv v2.0.1+incompatible h1:UBdAOUP5p4RWqPBg048CAvpKN+vxiaj6gdUUzhl4XmI=
github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU= github.com/peterbourgon/diskv v2.0.1+incompatible/go.mod h1:uqqh8zWWbv1HBMNONnaR/tNboyR3/BZd58JJSHlUSCU=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
@ -512,8 +514,6 @@ k8s.io/kubectl v0.36.4 h1:xZd9g1bFBd7hpb1oKjK8lT9jRL18dtgr4DAPQG1Oksk=
k8s.io/kubectl v0.36.4/go.mod h1:STWlr78cdEa1hHpr55wpcboaqchvfDueKRNDa1zOd1w= k8s.io/kubectl v0.36.4/go.mod h1:STWlr78cdEa1hHpr55wpcboaqchvfDueKRNDa1zOd1w=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2 h1:wU4tMEhLGgIbLvXQb1cfN+EcM0wf7zC6CPF+C79jroc=
k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk= k8s.io/utils v0.0.0-20260507154919-ff6756f316d2/go.mod h1:xDxuJ0whA3d0I4mf/C4ppKHxXynQ+fxnkmQH0vTHnuk=
oras.land/oras-go/v2 v2.6.2 h1:N04RXngAp1LJKTG6ifz3xHPipasEkWr+hFmInja5YKo=
oras.land/oras-go/v2 v2.6.2/go.mod h1:PlTtg4JTDJkDe8yVHpM2wz7/YDc00GVas+i4jAW2TZ4=
sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4= sigs.k8s.io/controller-runtime v0.24.1 h1:miPEwrmirImAvgME1L9qebGHrOnGJoVmVdtOU9fRfo4=
sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw= sigs.k8s.io/controller-runtime v0.24.1/go.mod h1:vFkfY5fGt5xAC/sKb8IBFKgWPNKG9OUG29dR8Y2wImw=
sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg= sigs.k8s.io/json v0.0.0-20250730193827-2d320260d730 h1:IpInykpT6ceI+QxKBbEflcR5EXP7sU1kvOlxwZh5txg=

@ -34,13 +34,16 @@ import (
"github.com/Masterminds/semver/v3" "github.com/Masterminds/semver/v3"
"github.com/opencontainers/image-spec/specs-go" "github.com/opencontainers/image-spec/specs-go"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2" "github.com/oras-project/oras-go/v3"
"oras.land/oras-go/v2/content/memory" "github.com/oras-project/oras-go/v3/content/memory"
"oras.land/oras-go/v2/registry" "github.com/oras-project/oras-go/v3/registry/remote"
"oras.land/oras-go/v2/registry/remote" "github.com/oras-project/oras-go/v3/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/auth" remoteconfig "github.com/oras-project/oras-go/v3/registry/remote/config"
"oras.land/oras-go/v2/registry/remote/credentials" "github.com/oras-project/oras-go/v3/registry/remote/credentials"
"oras.land/oras-go/v2/registry/remote/retry" "github.com/oras-project/oras-go/v3/registry/remote/policy"
"github.com/oras-project/oras-go/v3/registry/remote/properties"
"github.com/oras-project/oras-go/v3/registry/remote/retry"
"github.com/oras-project/oras-go/v3/registry/remote/signature"
"helm.sh/helm/v4/internal/version" "helm.sh/helm/v4/internal/version"
chart "helm.sh/helm/v4/pkg/chart/v2" chart "helm.sh/helm/v4/pkg/chart/v2"
@ -54,6 +57,14 @@ storing semantic versions, Helm adopts the convention of changing plus (+) to
an underscore (_) in chart version tags when pushing to a registry and back to an underscore (_) in chart version tags when pushing to a registry and back to
a plus (+) when pulling from a registry.` a plus (+) when pulling from a registry.`
// ConfigOptions specifies override paths for container ecosystem config files.
type ConfigOptions struct {
RegistriesConfigPath string
PolicyConfigPath string
CertsDirPaths []string
ContainersAuthPath string
}
type ( type (
// RemoteClient shadows the ORAS remote.Client interface // RemoteClient shadows the ORAS remote.Client interface
// (hiding the ORAS type from Helm client visibility) // (hiding the ORAS type from Helm client visibility)
@ -76,6 +87,17 @@ type (
credentialsStore credentials.Store credentialsStore credentials.Store
httpClient *http.Client httpClient *http.Client
plainHTTP bool plainHTTP bool
// v3 config-driven fields
configs *remoteconfig.Configs
builder *remote.ClientBuilder
policyEvaluator *policy.Evaluator
signatureVerification bool
configOptions ConfigOptions
insecure bool
certFile string
keyFile string
caFile string
customHTTPClient bool // true when ClientOptHTTPClient or ClientOptAuthorizer was used
} }
// ClientOption allows specifying various settings configurable by the user for overriding the defaults // ClientOption allows specifying various settings configurable by the user for overriding the defaults
@ -103,21 +125,66 @@ func NewClient(options ...ClientOption) (*Client, error) {
storeOptions := credentials.StoreOptions{ storeOptions := credentials.StoreOptions{
AllowPlaintextPut: true, AllowPlaintextPut: true,
DetectDefaultNativeStore: true,
} }
store, err := credentials.NewStore(client.credentialsFile, storeOptions)
// Primary credentials store (Helm's own file) — used for Login/Logout persistence.
helmStore, err := credentials.NewStore(client.credentialsFile, storeOptions)
if err != nil { if err != nil {
return nil, err return nil, err
} }
dockerStore, err := credentials.NewStoreFromDocker(storeOptions) client.credentialsStore = helmStore
// Load the full container ecosystem config stack: Docker config.json,
// containers auth.json, registries.conf, policy.json, certs.d, registries.d.
// Missing files are silently skipped.
loaderOpts := remoteconfig.LoadConfigsOptions{
RegistriesConfigPath: client.configOptions.RegistriesConfigPath,
PolicyConfigPath: client.configOptions.PolicyConfigPath,
CertsDirPaths: client.configOptions.CertsDirPaths,
ContainersAuthPath: client.configOptions.ContainersAuthPath,
}
configs, err := remoteconfig.LoadConfigsWithOptions(loaderOpts)
if err != nil {
return nil, fmt.Errorf("failed to load registry configurations: %w", err)
}
client.configs = configs
// Only build from configs when not overridden by ClientOptPolicyEvaluator.
if configs.PolicyConfig != nil && client.policyEvaluator == nil {
evaluator, err := configs.PolicyEvaluator()
if err != nil { if err != nil {
// should only fail if user home directory can't be determined return nil, fmt.Errorf("failed to build policy evaluator: %w", err)
client.credentialsStore = store }
client.policyEvaluator = evaluator
}
// Build the combined credential store for read operations:
// 1. Helm's own credentials file (highest priority, stores login results)
// 2. Docker config.json + containers auth.json from the full config stack
var credStore credentials.Store
if configStore, err := configs.CredentialStore(storeOptions); err == nil {
credStore = credentials.NewStoreWithFallbacks(helmStore, configStore)
} else { } else {
// use Helm credentials with fallback to Docker credStore = helmStore
client.credentialsStore = credentials.NewStoreWithFallbacks(store, dockerStore) }
// Build the ClientBuilder used by the config-driven repository creation path.
var logger *slog.Logger
if client.debug {
logger = slog.Default()
}
builder := remote.NewClientBuilder()
builder.CredentialStore = credStore
builder.UserAgent = version.GetUserAgent()
builder.Logger = logger
builder.PolicyEvaluator = client.policyEvaluator
if !client.enableCache {
builder.CacheFactory = nil
} }
client.builder = builder
// Build the legacy auth.Client used when a custom HTTP client or authorizer
// was provided (customHTTPClient=true path).
if client.authorizer == nil { if client.authorizer == nil {
authorizer := auth.Client{ authorizer := auth.Client{
Client: client.httpClient, Client: client.httpClient,
@ -125,11 +192,11 @@ func NewClient(options ...ClientOption) (*Client, error) {
authorizer.SetUserAgent(version.GetUserAgent()) authorizer.SetUserAgent(version.GetUserAgent())
if client.username != "" && client.password != "" { if client.username != "" && client.password != "" {
authorizer.Credential = func(_ context.Context, _ string) (auth.Credential, error) { authorizer.CredentialFunc = func(_ context.Context, _ string) (credentials.Credential, error) {
return auth.Credential{Username: client.username, Password: client.password}, nil return credentials.Credential{Username: client.username, Password: client.password}, nil
} }
} else { } else {
authorizer.Credential = credentials.Credential(client.credentialsStore) authorizer.CredentialFunc = remote.NewCredentialFunc(credStore)
} }
if client.enableCache { if client.enableCache {
@ -141,6 +208,118 @@ func NewClient(options ...ClientOption) (*Client, error) {
return client, nil return client, nil
} }
// applyOverrides applies client-level CLI flag overrides to registry properties.
func (c *Client) applyOverrides(props *properties.Registry) {
if c.plainHTTP {
props.Transport.PlainHTTP = true
}
if c.insecure {
props.Transport.Insecure = true
}
if c.certFile != "" && c.keyFile != "" {
props.Transport.Cert = c.certFile
props.Transport.Key = c.keyFile
}
if c.caFile != "" {
props.Transport.CACerts = append(props.Transport.CACerts, c.caFile)
}
if c.username != "" && c.password != "" {
props.Credential = credentials.Credential{Username: c.username, Password: c.password}
}
}
// newRepository creates a configured remote.Repository for the given reference.
//
// When a custom HTTP client or authorizer was provided, the legacy path is used
// (direct assignment of c.authorizer). Otherwise, the full config-driven path is
// used: registry properties are resolved from registries.conf and certs.d, CLI
// overrides are applied, and the repository is built via NewRepositoryWithProperties.
func (c *Client) newRepository(ref string) (*remote.Repository, error) {
if c.customHTTPClient {
// Legacy path: use c.authorizer directly (preserves custom TLS transport).
repo, err := remote.NewRepository(ref)
if err != nil {
return nil, err
}
repo.Registry.PlainHTTP = c.plainHTTP
if c.registryAuthorizer != nil {
repo.Registry.Client = c.registryAuthorizer
} else {
repo.Registry.Client = c.authorizer
}
repo.Registry.Policy = c.policyEvaluator
return repo, nil
}
// Config-driven path: resolve properties from registries.conf, certs.d, etc.
props, err := c.configs.RegistryProperties(ref)
if err != nil {
return nil, err
}
c.applyOverrides(props)
builder := c.builder
if props.Reference.Repository != "" && builder.CredentialStore != nil {
builderCopy := *builder
builderCopy.CredentialStore = &namespacedStore{
inner: builder.CredentialStore,
repository: props.Reference.Repository,
}
builder = &builderCopy
}
repo, err := remote.NewRepositoryWithProperties(props, builder)
if err != nil {
return nil, err
}
if c.signatureVerification && c.configs.RegistriesDConfig != nil && c.policyEvaluator != nil {
scope := props.Reference.Registry
if props.Reference.Repository != "" {
scope += "/" + props.Reference.Repository
}
verifier := signature.NewSignedByVerifierFromConfig(c.configs.RegistriesDConfig, scope)
if verifier != nil {
scopedEval, err := c.configs.PolicyEvaluator(policy.WithSignedByVerifier(verifier))
if err == nil && scopedEval != nil && repo.Registry.Policy == nil {
repo.Registry.Policy = scopedEval
}
}
}
return repo, nil
}
// newRegistry creates a configured remote.Registry for the given host (used by Login).
func (c *Client) newRegistry(host string) (*remote.Registry, error) {
if c.customHTTPClient {
// Legacy path: use c.authorizer directly.
reg, err := remote.NewRegistry(host)
if err != nil {
return nil, err
}
reg.PlainHTTP = c.plainHTTP
if c.registryAuthorizer != nil {
reg.Client = c.registryAuthorizer
} else {
reg.Client = c.authorizer
}
return reg, nil
}
// Config-driven path: construct properties for a host-only reference
// (no repository path) and apply CLI overrides + certs.d.
props := properties.NewRegistryFromReference(properties.Reference{Registry: host})
// Apply insecure setting from registries.conf if the host is found there.
if c.configs.RegistriesConfig != nil {
if reg := c.configs.RegistriesConfig.FindRegistry(host); reg != nil && reg.Insecure {
props.Transport.Insecure = true
}
}
// Apply per-host TLS certificates from certs.d.
if len(c.configs.CertsDirPaths) > 0 {
if certs, err := remoteconfig.LoadCertsDirFromPaths(host, c.configs.CertsDirPaths); err == nil && certs != nil {
certs.ApplyToTransport(&props.Transport)
}
}
c.applyOverrides(props)
return remote.NewRegistryWithProperties(props, c.builder)
}
// Generic returns a GenericClient for low-level OCI operations // Generic returns a GenericClient for low-level OCI operations
func (c *Client) Generic() *GenericClient { func (c *Client) Generic() *GenericClient {
return NewGenericClient(c) return NewGenericClient(c)
@ -182,6 +361,7 @@ func ClientOptWriter(out io.Writer) ClientOption {
func ClientOptAuthorizer(authorizer auth.Client) ClientOption { func ClientOptAuthorizer(authorizer auth.Client) ClientOption {
return func(client *Client) { return func(client *Client) {
client.authorizer = &authorizer client.authorizer = &authorizer
client.customHTTPClient = true
} }
} }
@ -203,9 +383,12 @@ func ClientOptCredentialsFile(credentialsFile string) ClientOption {
} }
// ClientOptHTTPClient returns a function that sets the HTTP client for the registry client. // ClientOptHTTPClient returns a function that sets the HTTP client for the registry client.
// When a custom HTTP client is provided, the legacy repository creation path is used so
// that TLS configuration in the custom transport is preserved.
func ClientOptHTTPClient(httpClient *http.Client) ClientOption { func ClientOptHTTPClient(httpClient *http.Client) ClientOption {
return func(client *Client) { return func(client *Client) {
client.httpClient = httpClient client.httpClient = httpClient
client.customHTTPClient = true
} }
} }
@ -217,6 +400,28 @@ func ClientOptPlainHTTP() ClientOption {
} }
} }
// ClientOptPolicyEvaluator returns a function that sets a custom policy evaluator on the client.
func ClientOptPolicyEvaluator(e *policy.Evaluator) ClientOption {
return func(c *Client) {
c.policyEvaluator = e
}
}
// ClientOptSignatureVerification returns a function that enables or disables
// GPG/simple-signing signature verification via registries.d lookaside storage.
func ClientOptSignatureVerification(enabled bool) ClientOption {
return func(c *Client) {
c.signatureVerification = enabled
}
}
// ClientOptConfigOptions returns a function that overrides default config file paths.
func ClientOptConfigOptions(o ConfigOptions) ClientOption {
return func(c *Client) {
c.configOptions = o
}
}
type ( type (
// LoginOption allows specifying various settings on login // LoginOption allows specifying various settings on login
LoginOption func(*loginOperation) LoginOption func(*loginOperation)
@ -224,6 +429,7 @@ type (
loginOperation struct { loginOperation struct {
host string host string
client *Client client *Client
err error
} }
) )
@ -238,37 +444,93 @@ func warnIfHostHasPath(host string) bool {
return false return false
} }
// noopStore is a credentials.Store that performs no persistence. It is used
// during namespaced login to verify credentials with a registry ping without
// causing remote.Login to store credentials under the hostname-only key.
type noopStore struct{}
func (noopStore) Get(_ context.Context, _ string) (credentials.Credential, error) {
return credentials.EmptyCredential, nil
}
func (noopStore) Put(_ context.Context, _ string, _ credentials.Credential) error { return nil }
func (noopStore) Delete(_ context.Context, _ string) error { return nil }
// namespacedStore wraps a credentials.Store and performs hierarchical
// credential lookup: it tries "hostname/full/repo", then "hostname/partial",
// then falls back to "hostname" for each auth challenge.
type namespacedStore struct {
inner credentials.Store
repository string
}
func (ns *namespacedStore) Get(ctx context.Context, serverAddress string) (credentials.Credential, error) {
parts := strings.Split(ns.repository, "/")
for i := len(parts); i > 0; i-- {
key := serverAddress + "/" + strings.Join(parts[:i], "/")
if cred, err := ns.inner.Get(ctx, key); err == nil && cred != credentials.EmptyCredential {
return cred, nil
}
}
return ns.inner.Get(ctx, serverAddress)
}
func (ns *namespacedStore) Put(ctx context.Context, serverAddress string, cred credentials.Credential) error {
return ns.inner.Put(ctx, serverAddress, cred)
}
func (ns *namespacedStore) Delete(ctx context.Context, serverAddress string) error {
return ns.inner.Delete(ctx, serverAddress)
}
// Login authenticates the client with a remote OCI registry using the provided host and options. // Login authenticates the client with a remote OCI registry using the provided host and options.
func (c *Client) Login(host string, options ...LoginOption) error { func (c *Client) Login(host string, options ...LoginOption) error {
op := &loginOperation{host: host, client: c}
for _, option := range options { for _, option := range options {
option(&loginOperation{host, c}) option(op)
}
if op.err != nil {
return op.err
} }
warnIfHostHasPath(host) // Separate the registry hostname from any namespace path.
// e.g., "localhost:8000/myrepo" → registryHost="localhost:8000", namespacePath="myrepo"
registryHost, namespacePath, hasNamespace := strings.Cut(host, "/")
reg, err := remote.NewRegistry(host) // Determine canonical host from registries.conf Location rewrite.
// We use the original (alias) host for newRegistry so its transport
// settings (Insecure, certs.d) are preserved, then redirect the
// authentication endpoint and credential key to the canonical host.
canonicalHost := registryHost
if c.configs != nil && c.configs.RegistriesConfig != nil {
if regCfg := c.configs.RegistriesConfig.FindRegistry(registryHost); regCfg != nil && regCfg.Location != "" {
canonicalHost = regCfg.Location
}
}
reg, err := c.newRegistry(registryHost)
if err != nil { if err != nil {
return err return err
} }
reg.PlainHTTP = c.plainHTTP reg.Reference.Registry = canonicalHost
cred := auth.Credential{Username: c.username, Password: c.password}
c.authorizer.ForceAttemptOAuth2 = true
reg.Client = c.authorizer
cred := credentials.Credential{Username: c.username, Password: c.password}
ctx := context.Background() ctx := context.Background()
if err := reg.Ping(ctx); err != nil {
c.authorizer.ForceAttemptOAuth2 = false if !hasNamespace {
if err := reg.Ping(ctx); err != nil { // Standard hostname-only login: verify and store under the hostname.
return fmt.Errorf("authenticating to %q: %w", host, err) if err := remote.Login(ctx, c.credentialsStore, reg, cred); err != nil {
return fmt.Errorf("authenticating to %q: %w", canonicalHost, err)
} }
} else {
// Namespaced login: verify the credential against the hostname,
// then store under the namespaced key only.
if err := remote.Login(ctx, noopStore{}, reg, cred); err != nil {
return fmt.Errorf("authenticating to %q: %w", canonicalHost, err)
}
namespacedKey := canonicalHost + "/" + namespacePath
if err := c.credentialsStore.Put(ctx, namespacedKey, cred); err != nil {
return fmt.Errorf("storing credentials for %q: %w", namespacedKey, err)
} }
// Always restore to false after probing, to avoid forcing POST to token endpoints like GHCR.
c.authorizer.ForceAttemptOAuth2 = false
key := credentials.ServerAddressFromRegistry(host)
key = credentials.ServerAddressFromHostname(key)
if err := c.credentialsStore.Put(ctx, key, cred); err != nil {
return err
} }
_, _ = fmt.Fprintln(c.out, "Login Succeeded") _, _ = fmt.Fprintln(c.out, "Login Succeeded")
@ -280,7 +542,6 @@ func LoginOptBasicAuth(username, password string) LoginOption {
return func(o *loginOperation) { return func(o *loginOperation) {
o.client.username = username o.client.username = username
o.client.password = password o.client.password = password
o.client.authorizer.Credential = auth.StaticCredential(o.host, auth.Credential{Username: username, Password: password})
} }
} }
@ -333,11 +594,13 @@ func ensureTLSConfig(client *auth.Client, setConfig *tls.Config) (*tls.Config, e
// LoginOptInsecure returns a function that sets the insecure setting on login // LoginOptInsecure returns a function that sets the insecure setting on login
func LoginOptInsecure(insecure bool) LoginOption { func LoginOptInsecure(insecure bool) LoginOption {
return func(o *loginOperation) { return func(o *loginOperation) {
o.client.insecure = insecure
// Also update the authorizer transport for the legacy path (customHTTPClient=true).
tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil) tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil)
if err != nil { if err != nil {
panic(err) o.err = err
return
} }
tlsConfig.InsecureSkipVerify = insecure tlsConfig.InsecureSkipVerify = insecure
} }
} }
@ -348,11 +611,19 @@ func LoginOptTLSClientConfig(certFile, keyFile, caFile string) LoginOption {
if (certFile == "" || keyFile == "") && caFile == "" { if (certFile == "" || keyFile == "") && caFile == "" {
return return
} }
// Set file path fields for the config-driven path.
if certFile != "" && keyFile != "" {
o.client.certFile = certFile
o.client.keyFile = keyFile
}
if caFile != "" {
o.client.caFile = caFile
}
// Also update the authorizer transport for the legacy path (customHTTPClient=true).
tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil) tlsConfig, err := ensureTLSConfig(o.client.authorizer, nil)
if err != nil { if err != nil {
panic(err) panic(err)
} }
if certFile != "" && keyFile != "" { if certFile != "" && keyFile != "" {
authCert, err := tls.LoadX509KeyPair(certFile, keyFile) authCert, err := tls.LoadX509KeyPair(certFile, keyFile)
if err != nil { if err != nil {
@ -360,7 +631,6 @@ func LoginOptTLSClientConfig(certFile, keyFile, caFile string) LoginOption {
} }
tlsConfig.Certificates = []tls.Certificate{authCert} tlsConfig.Certificates = []tls.Certificate{authCert}
} }
if caFile != "" { if caFile != "" {
certPool := x509.NewCertPool() certPool := x509.NewCertPool()
ca, err := os.ReadFile(caFile) ca, err := os.ReadFile(caFile)
@ -400,7 +670,21 @@ func (c *Client) Logout(host string, opts ...LogoutOption) error {
opt(operation) opt(operation)
} }
if err := credentials.Logout(context.Background(), c.credentialsStore, host); err != nil { // Extract registry hostname for Location rewrite lookup.
registryHost, namespacePath, hasNamespace := strings.Cut(host, "/")
canonicalHost := registryHost
if c.configs != nil && c.configs.RegistriesConfig != nil {
if regCfg := c.configs.RegistriesConfig.FindRegistry(registryHost); regCfg != nil && regCfg.Location != "" {
canonicalHost = regCfg.Location
}
}
if hasNamespace {
host = canonicalHost + "/" + namespacePath
} else {
host = canonicalHost
}
if err := remote.Logout(context.Background(), c.credentialsStore, host); err != nil {
return err return err
} }
_, _ = fmt.Fprintf(c.out, "Removing login credentials for %s\n", host) _, _ = fmt.Fprintf(c.out, "Removing login credentials for %s\n", host)
@ -712,12 +996,10 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu
return nil, err return nil, err
} }
repository, err := remote.NewRepository(parsedRef.String()) repository, err := c.newRepository(parsedRef.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
repository.PlainHTTP = c.plainHTTP
repository.Client = c.authorizer
ctx = withScopeHint(ctx, repository, auth.ActionPull, auth.ActionPush) ctx = withScopeHint(ctx, repository, auth.ActionPull, auth.ActionPush)
@ -752,7 +1034,7 @@ func (c *Client) Push(data []byte, ref string, options ...PushOption) (*PushResu
} }
_, _ = fmt.Fprintf(c.out, "Pushed: %s\n", result.Ref) _, _ = fmt.Fprintf(c.out, "Pushed: %s\n", result.Ref)
_, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest) _, _ = fmt.Fprintf(c.out, "Digest: %s\n", result.Manifest.Digest)
if strings.Contains(parsedRef.orasReference.Reference, "_") { if strings.Contains(parsedRef.Tag, "_") {
_, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref) _, _ = fmt.Fprintf(c.out, "%s contains an underscore.\n", result.Ref)
_, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n") _, _ = fmt.Fprint(c.out, registryUnderscoreMessage+"\n")
} }
@ -783,18 +1065,16 @@ func PushOptCreationTime(creationTime string) PushOption {
// Tags provides a sorted list all semver compliant tags for a given repository // Tags provides a sorted list all semver compliant tags for a given repository
func (c *Client) Tags(ref string) ([]string, error) { func (c *Client) Tags(ref string) ([]string, error) {
parsedReference, err := registry.ParseReference(ref) parsedReference, err := properties.NewReference(ref)
if err != nil { if err != nil {
return nil, err return nil, err
} }
ctx := context.Background() ctx := context.Background()
repository, err := remote.NewRepository(parsedReference.String()) repository, err := c.newRepository(parsedReference.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
repository.PlainHTTP = c.plainHTTP
repository.Client = c.authorizer
var tagVersions []*semver.Version var tagVersions []*semver.Version
err = repository.Tags(ctx, "", func(tags []string) error { err = repository.Tags(ctx, "", func(tags []string) error {
@ -827,12 +1107,10 @@ func (c *Client) Tags(ref string) ([]string, error) {
// Resolve a reference to a descriptor. // Resolve a reference to a descriptor.
func (c *Client) Resolve(ref string) (desc ocispec.Descriptor, err error) { func (c *Client) Resolve(ref string) (desc ocispec.Descriptor, err error) {
remoteRepository, err := remote.NewRepository(ref) remoteRepository, err := c.newRepository(ref)
if err != nil { if err != nil {
return desc, err return desc, err
} }
remoteRepository.PlainHTTP = c.plainHTTP
remoteRepository.Client = c.authorizer
parsedReference, err := newReference(ref) parsedReference, err := newReference(ref)
if err != nil { if err != nil {
@ -931,13 +1209,11 @@ func (c *Client) tagManifest(ctx context.Context, memoryStore *memory.Store,
manifestData, parsedRef.String()) manifestData, parsedRef.String())
} }
// add actions when request a registry authentication token(jwt) // withScopeHint hints the auth client to request a token covering all the given
// example1. when we want to pull 'testrepo/local-subchart' we can send below url, and 'pull' is the action // actions in a single request. Without this, pushing to a token-auth registry
// auth?scope=repository%3Atestrepo%2Flocal-subchart%3Apull&service=testservice // first requests a [pull] scope (which fails for a not-yet-existing repository
// example2. when we want to push 'testrepo/local-subchart' we can send below url, and 'pull%2Cpush' are the actions // path), making it hard to mint a valid token. Hinting [pull,push] up front
// auth?scope=repository%3Atestrepo%2Flocal-subchart%3Apull%2Cpush&service=testservice // produces a single correct token request.
// we can set the actions like below
// example) ctx = withScopeHint(ctx, repository, auth.ActionPush, auth.ActionPull)
func withScopeHint(ctx context.Context, repo *remote.Repository, actions ...string) context.Context { func withScopeHint(ctx context.Context, repo *remote.Repository, actions ...string) context.Context {
return auth.AppendRepositoryScope(ctx, repo.Reference, actions...) return auth.AppendRepositoryScope(ctx, repo.Reference(), actions...)
} }

@ -20,8 +20,8 @@ import (
"os" "os"
"testing" "testing"
"github.com/oras-project/oras-go/v3/content"
"github.com/stretchr/testify/suite" "github.com/stretchr/testify/suite"
"oras.land/oras-go/v2/content"
) )
type HTTPRegistryClientTestSuite struct { type HTTPRegistryClientTestSuite struct {

@ -21,7 +21,6 @@ import (
"net" "net"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url"
"os" "os"
"testing" "testing"
"time" "time"
@ -33,6 +32,28 @@ type RegistryScopeTestSuite struct {
TestRegistry TestRegistry
} }
// authRequest captures the fields of a token request that the test auth server
// receives. The oras-go v3 auth client may issue the request either as a GET
// (query params) or as an OAuth2 POST (form body); reading the parsed form
// covers both.
type authRequest struct {
path string
service string
scope string
}
func captureAuthRequest(requests chan<- authRequest) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
_ = r.ParseForm()
requests <- authRequest{
path: r.URL.Path,
service: r.Form.Get("service"),
scope: r.Form.Get("scope"),
}
w.WriteHeader(http.StatusOK)
}
}
func (suite *RegistryScopeTestSuite) SetupSuite() { func (suite *RegistryScopeTestSuite) SetupSuite() {
// Set up a plain-HTTP registry that uses token auth. The token realm is // Set up a plain-HTTP registry that uses token auth. The token realm is
// served over http (see setup), so the registry must be contacted over // served over http (see setup), so the registry must be contacted over
@ -47,22 +68,12 @@ func (suite *RegistryScopeTestSuite) TearDownSuite() {
} }
func (suite *RegistryScopeTestSuite) Test_1_Check_Push_Request_Scope() { func (suite *RegistryScopeTestSuite) Test_1_Check_Push_Request_Scope() {
requestURL := make(chan string, 1) requests := make(chan authRequest, 1)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture only the first auth request; never block the handler if the
// client happens to retry, so the auth server always responds and the
// push/pull flow can't deadlock waiting on us.
select {
case requestURL <- r.URL.String():
default:
}
w.WriteHeader(http.StatusOK)
})
lnCfg := net.ListenConfig{} lnCfg := net.ListenConfig{}
listener, err := lnCfg.Listen(suite.T().Context(), "tcp", suite.AuthServerHost) listener, err := lnCfg.Listen(suite.T().Context(), "tcp", suite.AuthServerHost)
suite.Require().NoError(err, "no error creating server listener") suite.Require().NoError(err, "no error creating server listener")
ts := httptest.NewUnstartedServer(handler) ts := httptest.NewUnstartedServer(captureAuthRequest(requests))
ts.Listener = listener ts.Listener = listener
ts.Start() ts.Start()
defer ts.Close() defer ts.Close()
@ -79,41 +90,26 @@ func (suite *RegistryScopeTestSuite) Test_1_Check_Push_Request_Scope() {
// check the url that authentication server received // check the url that authentication server received
select { select {
case urlStr := <-requestURL: case req := <-requests:
u, err := url.Parse(urlStr) suite.Equal("/auth", req.path)
suite.Require().NoError(err, "no error parsing requested URL") suite.Equal("testservice", req.service)
suite.Contains(req.scope, "repository:testrepo/local-subchart:pull,push")
suite.Equal("/auth", u.Path)
suite.Equal("testservice", u.Query().Get("service"))
scope := u.Query().Get("scope")
suite.Contains(scope, "repository:testrepo/local-subchart:pull,push")
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
suite.T().Fatal("timeout waiting for auth request") suite.T().Fatal("timeout waiting for auth request")
} }
} }
func (suite *RegistryScopeTestSuite) Test_2_Check_Pull_Request_Scope() { func (suite *RegistryScopeTestSuite) Test_2_Check_Pull_Request_Scope() {
requestURL := make(chan string, 1) requests := make(chan authRequest, 1)
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
// Capture only the first auth request; never block the handler if the
// client happens to retry, so the auth server always responds and the
// push/pull flow can't deadlock waiting on us.
select {
case requestURL <- r.URL.String():
default:
}
w.WriteHeader(http.StatusOK)
})
lnCfg := net.ListenConfig{} lnCfg := net.ListenConfig{}
listener, err := lnCfg.Listen(suite.T().Context(), "tcp", suite.AuthServerHost) listener, err := lnCfg.Listen(suite.T().Context(), "tcp", suite.AuthServerHost)
suite.Require().NoError(err, "no error creating server listener") suite.Require().NoError(err, "no error creating server listener")
ts := httptest.NewUnstartedServer(handler) ts := httptest.NewUnstartedServer(captureAuthRequest(requests))
ts.Listener = listener ts.Listener = listener
ts.Start() ts.Start()
defer ts.Close() defer ts.Close()
// Load test chart (to build ref pushed in previous test)
// Simple pull, chart only // Simple pull, chart only
chartData, err := os.ReadFile("../downloader/testdata/local-subchart-0.1.0.tgz") chartData, err := os.ReadFile("../downloader/testdata/local-subchart-0.1.0.tgz")
suite.Require().NoError(err, "no error loading test chart") suite.Require().NoError(err, "no error loading test chart")
@ -125,14 +121,10 @@ func (suite *RegistryScopeTestSuite) Test_2_Check_Pull_Request_Scope() {
// check the url that authentication server received // check the url that authentication server received
select { select {
case urlStr := <-requestURL: case req := <-requests:
u, err := url.Parse(urlStr) suite.Equal("/auth", req.path)
suite.Require().NoError(err, "no error parsing requested URL") suite.Equal("testservice", req.service)
suite.Contains(req.scope, "repository:testrepo/local-subchart:pull")
suite.Equal("/auth", u.Path)
suite.Equal("testservice", u.Query().Get("service"))
scope := u.Query().Get("scope")
suite.Contains(scope, "repository:testrepo/local-subchart:pull")
case <-time.After(5 * time.Second): case <-time.After(5 * time.Second):
suite.T().Fatal("timeout waiting for auth request") suite.T().Fatal("timeout waiting for auth request")
} }

@ -17,17 +17,22 @@ limitations under the License.
package registry package registry
import ( import (
"context"
"encoding/json"
"fmt"
"io" "io"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"path/filepath" "path/filepath"
"strings" "strings"
"testing" "testing"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"github.com/stretchr/testify/assert" "github.com/oras-project/oras-go/v3/content/memory"
"github.com/oras-project/oras-go/v3/registry/remote/credentials"
"github.com/oras-project/oras-go/v3/registry/remote/policy"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"oras.land/oras-go/v2/content/memory"
) )
// Inspired by oras test // Inspired by oras test
@ -57,8 +62,8 @@ func TestTagManifestTransformsReferences(t *testing.T) {
require.Error(t, err, "Should NOT find the reference with the original +") require.Error(t, err, "Should NOT find the reference with the original +")
} }
// Verifies that Login always restores ForceAttemptOAuth2 to false on success. // Verifies that the authorizer is set on a new client and Login succeeds against a reachable registry.
func TestLogin_ResetsForceAttemptOAuth2_OnSuccess(t *testing.T) { func TestLogin_AuthorizerSetAndSucceeds(t *testing.T) {
t.Parallel() t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@ -80,17 +85,18 @@ func TestLogin_ResetsForceAttemptOAuth2_OnSuccess(t *testing.T) {
) )
require.NoError(t, err, "NewClient error") require.NoError(t, err, "NewClient error")
require.NotNil(t, c.authorizer) if c.authorizer == nil {
require.False(t, c.authorizer.ForceAttemptOAuth2, "expected ForceAttemptOAuth2 default to be false") t.Fatal("expected authorizer to be set")
}
// Call Login with plain HTTP against our test server // Call Login with plain HTTP against our test server
require.NoError(t, c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")), "Login error") if err := c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")); err != nil {
t.Fatalf("Login error: %v", err)
assert.False(t, c.authorizer.ForceAttemptOAuth2, "ForceAttemptOAuth2 should be false after successful Login") }
} }
// Verifies that Login restores ForceAttemptOAuth2 to false even when ping fails. // Verifies that Login returns an error when the registry is unreachable.
func TestLogin_ResetsForceAttemptOAuth2_OnFailure(t *testing.T) { func TestLogin_FailsWhenUnreachable(t *testing.T) {
t.Parallel() t.Parallel()
// Start and immediately close, so connections will fail // Start and immediately close, so connections will fail
@ -105,51 +111,228 @@ func TestLogin_ResetsForceAttemptOAuth2_OnFailure(t *testing.T) {
) )
require.NoError(t, err, "NewClient error") require.NoError(t, err, "NewClient error")
// Invoke Login, expect an error but ForceAttemptOAuth2 must end false // Invoke Login, expect an error since the server is closed
_ = c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")) if err := c.Login(host, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")); err == nil {
t.Error("expected Login to fail when server is unreachable")
}
}
func TestLogin_NamespacedAuth(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/" {
w.WriteHeader(http.StatusOK)
return
}
http.NotFound(w, r)
}))
defer srv.Close()
registryHost := strings.TrimPrefix(srv.URL, "http://")
namespacedHost := registryHost + "/myrepo"
// Pre-create a config.json with a sentinel auths entry so that
// IsAuthConfigured() returns true and the credentials package does not
// detect a native platform store (e.g., osxkeychain on macOS). This
// ensures the credential is written into the plaintext config file
// where we can inspect the storage key directly.
credFile := filepath.Join(t.TempDir(), "config.json")
err := os.WriteFile(credFile, []byte(`{"auths":{"_sentinel_":{}}}`), 0o600)
require.NoError(t, err)
c, err := NewClient(
ClientOptWriter(io.Discard),
ClientOptCredentialsFile(credFile),
)
require.NoError(t, err)
err = c.Login(namespacedHost, LoginOptPlainText(true), LoginOptBasicAuth("u", "p"))
require.NoError(t, err)
ctx := context.Background()
// Credential lookup by namespaced key succeeds.
cred, err := c.credentialsStore.Get(ctx, namespacedHost)
require.NoError(t, err)
require.Equal(t, "u", cred.Username)
assert.False(t, c.authorizer.ForceAttemptOAuth2, "ForceAttemptOAuth2 should be false after failed Login") // Verify that the credential is stored on disk under the namespaced key,
// and NOT under the hostname-only key. We inspect the JSON file directly
// because the FileStore's Get() falls back to a hostname-based lookup,
// which would mask whether the storage key itself is hostname-only or
// namespaced.
data, err := os.ReadFile(credFile)
require.NoError(t, err)
var parsed struct {
Auths map[string]any `json:"auths"`
}
require.NoError(t, json.Unmarshal(data, &parsed))
require.Contains(t, parsed.Auths, namespacedHost, "credential should be stored under namespaced key")
require.NotContains(t, parsed.Auths, registryHost, "credential should not be stored under hostname-only key")
} }
// TestWarnIfHostHasPath verifies that warnIfHostHasPath correctly detects path components. func TestNamespacedStore_HierarchicalLookup(t *testing.T) {
func TestWarnIfHostHasPath(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { inner := &memCredStore{creds: map[string]credentials.Credential{}}
name string ctx := context.Background()
host string
wantWarn bool // Store a credential under "localhost:5000/org".
}{ _ = inner.Put(ctx, "localhost:5000/org", credentials.Credential{Username: "orguser"})
{
name: "domain only", // Lookup for a deeper path should find it.
host: "ghcr.io", ns := &namespacedStore{inner: inner, repository: "org/repo"}
wantWarn: false, cred, err := ns.Get(ctx, "localhost:5000")
}, require.NoError(t, err)
{ require.Equal(t, "orguser", cred.Username)
name: "domain with port",
host: "localhost:8000", // Lookup for an unrelated path should NOT find it.
wantWarn: false, ns2 := &namespacedStore{inner: inner, repository: "other/repo"}
}, cred, err = ns2.Get(ctx, "localhost:5000")
{ require.NoError(t, err)
name: "domain with repository path", require.Empty(t, cred.Username)
host: "ghcr.io/terryhowe", }
wantWarn: true,
}, // memCredStore is a simple in-memory credentials.Store for testing.
{ type memCredStore struct {
name: "domain with nested path", creds map[string]credentials.Credential
host: "ghcr.io/terryhowe/myrepo", }
wantWarn: true,
}, func (m *memCredStore) Get(_ context.Context, serverAddress string) (credentials.Credential, error) {
{ return m.creds[serverAddress], nil
name: "localhost with port and path", }
host: "localhost:8000/myrepo",
wantWarn: true, func (m *memCredStore) Put(_ context.Context, serverAddress string, cred credentials.Credential) error {
}, m.creds[serverAddress] = cred
return nil
}
func (m *memCredStore) Delete(_ context.Context, serverAddress string) error {
delete(m.creds, serverAddress)
return nil
}
func TestNewClient_WithDenyAllPolicy(t *testing.T) {
t.Parallel()
denyPolicy := policy.NewRejectAllPolicy()
evaluator, err := policy.NewEvaluator(denyPolicy)
require.NoError(t, err)
credFile := filepath.Join(t.TempDir(), "config.json")
c, err := NewClient(
ClientOptWriter(io.Discard),
ClientOptCredentialsFile(credFile),
ClientOptPolicyEvaluator(evaluator),
)
require.NoError(t, err)
require.Same(t, evaluator, c.policyEvaluator)
}
func TestNewClient_WithConfigOptions(t *testing.T) {
t.Parallel()
credFile := filepath.Join(t.TempDir(), "config.json")
c, err := NewClient(
ClientOptWriter(io.Discard),
ClientOptCredentialsFile(credFile),
ClientOptConfigOptions(ConfigOptions{
RegistriesConfigPath: "/nonexistent/registries.conf",
}),
)
require.NoError(t, err) // nonexistent paths are silently skipped
require.NotNil(t, c)
}
func TestLogin_LocationRewrite(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/" {
w.WriteHeader(http.StatusOK)
return
} }
http.NotFound(w, r)
}))
defer srv.Close()
canonicalHost := strings.TrimPrefix(srv.URL, "http://")
aliasHost := "registry.example.test"
// Write a registries.conf that maps the alias to the canonical host.
registriesConf := filepath.Join(t.TempDir(), "registries.conf")
err := os.WriteFile(registriesConf, fmt.Appendf(nil,
"[[registry]]\nprefix = %q\nlocation = %q\n", aliasHost, canonicalHost,
), 0o600)
require.NoError(t, err)
credFile := filepath.Join(t.TempDir(), "config.json")
c, err := NewClient(
ClientOptWriter(io.Discard),
ClientOptCredentialsFile(credFile),
ClientOptConfigOptions(ConfigOptions{RegistriesConfigPath: registriesConf}),
)
require.NoError(t, err)
// Login via alias; the connection goes to the canonical host (plain HTTP).
require.NoError(t, c.Login(aliasHost, LoginOptPlainText(true), LoginOptBasicAuth("u", "p")))
// Credential must be stored under the canonical host, not the alias.
cred, err := c.credentialsStore.Get(context.Background(), canonicalHost)
require.NoError(t, err)
require.Equal(t, "u", cred.Username, "credential should be stored under canonical host")
aliasCred, err := c.credentialsStore.Get(context.Background(), aliasHost)
require.NoError(t, err)
require.Empty(t, aliasCred.Username, "credential must not be stored under alias")
}
// mockRemoteClient records whether Do was called, for use in registryAuthorizer tests.
type mockRemoteClient struct {
called bool
inner http.RoundTripper
}
func (m *mockRemoteClient) Do(req *http.Request) (*http.Response, error) {
m.called = true
return m.inner.RoundTrip(req)
}
for _, tt := range tests { func TestRegistryAuthorizer_UsedInLegacyPath(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Parallel()
assert.Equal(t, tt.wantWarn, warnIfHostHasPath(tt.host))
}) srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/v2/" {
w.WriteHeader(http.StatusOK)
return
}
// Return empty tag list for any tags request.
if strings.HasSuffix(r.URL.Path, "/tags/list") {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
w.Write([]byte(`{"name":"testchart","tags":[]}`))
return
} }
http.NotFound(w, r)
}))
defer srv.Close()
host := strings.TrimPrefix(srv.URL, "http://")
mock := &mockRemoteClient{inner: http.DefaultTransport}
credFile := filepath.Join(t.TempDir(), "config.json")
c, err := NewClient(
ClientOptWriter(io.Discard),
ClientOptCredentialsFile(credFile),
ClientOptHTTPClient(&http.Client{}), // triggers customHTTPClient=true (legacy path)
ClientOptRegistryAuthorizer(mock),
ClientOptPlainHTTP(),
)
require.NoError(t, err)
// Tags calls newRepository → legacy path → should use registryAuthorizer.
_, err = c.Tags(host + "/testchart")
require.NoError(t, err)
require.True(t, mock.called, "registryAuthorizer.Do should have been called in legacy path")
} }

@ -19,33 +19,20 @@ package registry
import ( import (
"context" "context"
"io" "io"
"net/http"
"slices" "slices"
"sort" "sort"
"sync" "sync"
ocispec "github.com/opencontainers/image-spec/specs-go/v1" ocispec "github.com/opencontainers/image-spec/specs-go/v1"
"oras.land/oras-go/v2" "github.com/oras-project/oras-go/v3"
"oras.land/oras-go/v2/content" "github.com/oras-project/oras-go/v3/content"
"oras.land/oras-go/v2/content/memory" "github.com/oras-project/oras-go/v3/content/memory"
"oras.land/oras-go/v2/registry/remote"
"oras.land/oras-go/v2/registry/remote/auth"
"oras.land/oras-go/v2/registry/remote/credentials"
) )
// GenericClient provides low-level OCI operations without artifact-specific assumptions // GenericClient provides low-level OCI operations without artifact-specific assumptions
type GenericClient struct { type GenericClient struct {
debug bool client *Client
enableCache bool
credentialsFile string
username string
password string
out io.Writer out io.Writer
authorizer *auth.Client
registryAuthorizer RemoteClient
credentialsStore credentials.Store
httpClient *http.Client
plainHTTP bool
} }
// GenericPullOptions configures a generic pull operation // GenericPullOptions configures a generic pull operation
@ -69,17 +56,8 @@ type GenericPullResult struct {
// NewGenericClient creates a new generic OCI client from an existing Client // NewGenericClient creates a new generic OCI client from an existing Client
func NewGenericClient(client *Client) *GenericClient { func NewGenericClient(client *Client) *GenericClient {
return &GenericClient{ return &GenericClient{
debug: client.debug, client: client,
enableCache: client.enableCache,
credentialsFile: client.credentialsFile,
username: client.username,
password: client.password,
out: client.out, out: client.out,
authorizer: client.authorizer,
registryAuthorizer: client.registryAuthorizer,
credentialsStore: client.credentialsStore,
httpClient: client.httpClient,
plainHTTP: client.plainHTTP,
} }
} }
@ -93,13 +71,10 @@ func (c *GenericClient) PullGeneric(ref string, options GenericPullOptions) (*Ge
memoryStore := memory.New() memoryStore := memory.New()
var descriptors []ocispec.Descriptor var descriptors []ocispec.Descriptor
// Set up a repository with authentication and configuration repository, err := c.client.newRepository(parsedRef.String())
repository, err := remote.NewRepository(parsedRef.String())
if err != nil { if err != nil {
return nil, err return nil, err
} }
repository.PlainHTTP = c.plainHTTP
repository.Client = c.authorizer
ctx := context.Background() ctx := context.Background()

@ -19,11 +19,11 @@ package registry
import ( import (
"strings" "strings"
"oras.land/oras-go/v2/registry" "github.com/oras-project/oras-go/v3/registry/remote/properties"
) )
type reference struct { type reference struct {
orasReference registry.Reference orasReference properties.Reference
Registry string Registry string
Repository string Repository string
Tag string Tag string
@ -60,13 +60,13 @@ func newReference(raw string) (result reference, err error) {
} }
} }
result.orasReference, err = registry.ParseReference(raw) result.orasReference, err = properties.NewReference(raw)
if err != nil { if err != nil {
return result, err return result, err
} }
result.Registry = result.orasReference.Registry result.Registry = result.orasReference.Registry
result.Repository = result.orasReference.Repository result.Repository = result.orasReference.Repository
result.Tag = result.orasReference.Reference result.Tag = result.orasReference.Tag
return result, nil return result, nil
} }

@ -27,7 +27,7 @@ func verify(t *testing.T, actual reference, registry, repository, tag, digest st
t.Helper() t.Helper()
assert.Equal(t, registry, actual.orasReference.Registry, "Oras reference registry") assert.Equal(t, registry, actual.orasReference.Registry, "Oras reference registry")
assert.Equal(t, repository, actual.orasReference.Repository, "Oras reference repository") assert.Equal(t, repository, actual.orasReference.Repository, "Oras reference repository")
assert.Equal(t, tag, actual.orasReference.Reference, "Oras reference reference") assert.Equal(t, tag, actual.orasReference.Tag, "Oras reference tag")
assert.Equal(t, registry, actual.Registry, "Registry") assert.Equal(t, registry, actual.Registry, "Registry")
assert.Equal(t, repository, actual.Repository, "Repository") assert.Equal(t, repository, actual.Repository, "Repository")
assert.Equal(t, tag, actual.Tag, "Tag") assert.Equal(t, tag, actual.Tag, "Tag")

@ -144,12 +144,12 @@ func setup(suite *TestRegistry, tlsEnabled, insecure bool, auth string) {
config.Storage = map[string]configuration.Parameters{"inmemory": map[string]any{}} config.Storage = map[string]configuration.Parameters{"inmemory": map[string]any{}}
if auth == "token" { if auth == "token" {
ln, err := lnCfg.Listen(suite.T().Context(), "tcp", "127.0.0.1:0") authLn, err := lnCfg.Listen(suite.T().Context(), "tcp", "127.0.0.1:0")
suite.Require().NoError(err, "no error finding free port for test auth server") suite.Require().NoError(err, "no error finding free port for test auth server")
defer ln.Close() defer func() { _ = authLn.Close() }()
// set test auth server host // set test auth server host
suite.AuthServerHost = ln.Addr().String() suite.AuthServerHost = authLn.Addr().String()
config.Auth = configuration.Auth{ config.Auth = configuration.Auth{
"token": configuration.Parameters{ "token": configuration.Parameters{

@ -27,7 +27,7 @@ import (
"strings" "strings"
"sync/atomic" "sync/atomic"
"oras.land/oras-go/v2/registry/remote/retry" "github.com/oras-project/oras-go/v3/registry/remote/retry"
) )
var ( var (

Loading…
Cancel
Save