pull/32065/merge
Terry Howe 1 month ago committed by GitHub
commit 9988bb19cb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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.3-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.3-dev h1:1tpitwyCDXMai5TfI/tJEOtBl2Av6fAu5CvGfpq2Xj4=
github.com/oras-project/oras-go/v3 v3.0.3-dev/go.mod h1:8eV2J5RW//YjOcI7SZKhqz2o0ivP3300wBB6Fgf2CIY=
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"
@ -76,6 +79,19 @@ 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
// registriesConfigPath overrides the registries.conf path; empty means
// the container ecosystem default search locations are used. Test-only.
registriesConfigPath string
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
@ -102,22 +118,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.
// Only registries.conf is overridable (empty means default search); all
// other config files always resolve to their default locations.
loaderOpts := remoteconfig.LoadConfigsOptions{
RegistriesConfigPath: client.registriesConfigPath,
}
configs, err := remoteconfig.LoadConfigsWithOptions(loaderOpts)
if err != nil { if err != nil {
// should only fail if user home directory can't be determined return nil, fmt.Errorf("failed to load registry configurations: %w", err)
client.credentialsStore = store }
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 {
return nil, fmt.Errorf("failed to build policy evaluator: %w", err)
}
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 +185,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 +201,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 +354,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 +376,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 +393,30 @@ 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
}
}
// withRegistriesConfigPath overrides the registries.conf path. It is unexported
// because the only consumer is hermetic tests; production always uses the
// container ecosystem default search locations.
func withRegistriesConfigPath(path string) ClientOption {
return func(c *Client) {
c.registriesConfigPath = path
}
}
type ( type (
// LoginOption allows specifying various settings on login // LoginOption allows specifying various settings on login
LoginOption func(*loginOperation) LoginOption func(*loginOperation)
@ -224,51 +424,97 @@ type (
loginOperation struct { loginOperation struct {
host string host string
client *Client client *Client
err error
} }
) )
// warnIfHostHasPath checks if the host contains a repository path and logs a warning if it does. // noopStore is a credentials.Store that performs no persistence. It is used
// Returns true if the host contains a path component (i.e., contains a '/'). // during namespaced login to verify credentials with a registry ping without
func warnIfHostHasPath(host string) bool { // causing remote.Login to store credentials under the hostname-only key.
if strings.Contains(host, "/") { type noopStore struct{}
registryHost, _, _ := strings.Cut(host, "/")
slog.Warn("registry login currently only supports registry hostname, not a repository path", "host", host, "suggested", registryHost) func (noopStore) Get(_ context.Context, _ string) (credentials.Credential, error) {
return true 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 false 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 err := reg.Ping(ctx); err != nil {
return fmt.Errorf("authenticating to %q: %w", host, err)
}
}
// Always restore to false after probing, to avoid forcing POST to token endpoints like GHCR.
c.authorizer.ForceAttemptOAuth2 = false
key := credentials.ServerAddressFromRegistry(host) if !hasNamespace {
key = credentials.ServerAddressFromHostname(key) // Standard hostname-only login: verify and store under the hostname.
if err := c.credentialsStore.Put(ctx, key, cred); err != nil { if err := remote.Login(ctx, c.credentialsStore, reg, cred); err != nil {
return err 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)
}
} }
_, _ = fmt.Fprintln(c.out, "Login Succeeded") _, _ = fmt.Fprintln(c.out, "Login Succeeded")
@ -280,7 +526,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 +578,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 +595,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 +615,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 +654,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 +980,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 +1018,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 +1049,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 +1091,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 +1193,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,226 @@ 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")
assert.False(t, c.authorizer.ForceAttemptOAuth2, "ForceAttemptOAuth2 should be false after failed Login") }
} }
// TestWarnIfHostHasPath verifies that warnIfHostHasPath correctly detects path components. func TestLogin_NamespacedAuth(t *testing.T) {
func TestWarnIfHostHasPath(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
name string if r.URL.Path == "/v2/" {
host string w.WriteHeader(http.StatusOK)
wantWarn bool return
}{ }
{ http.NotFound(w, r)
name: "domain only", }))
host: "ghcr.io", defer srv.Close()
wantWarn: false,
}, registryHost := strings.TrimPrefix(srv.URL, "http://")
{ namespacedHost := registryHost + "/myrepo"
name: "domain with port",
host: "localhost:8000", // Pre-create a config.json with a sentinel auths entry so that
wantWarn: false, // 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
name: "domain with repository path", // where we can inspect the storage key directly.
host: "ghcr.io/terryhowe", credFile := filepath.Join(t.TempDir(), "config.json")
wantWarn: true, err := os.WriteFile(credFile, []byte(`{"auths":{"_sentinel_":{}}}`), 0o600)
}, require.NoError(t, err)
{
name: "domain with nested path", c, err := NewClient(
host: "ghcr.io/terryhowe/myrepo", ClientOptWriter(io.Discard),
wantWarn: true, ClientOptCredentialsFile(credFile),
}, )
{ require.NoError(t, err)
name: "localhost with port and path",
host: "localhost:8000/myrepo",
wantWarn: true,
},
}
for _, tt := range tests { err = c.Login(namespacedHost, LoginOptPlainText(true), LoginOptBasicAuth("u", "p"))
t.Run(tt.name, func(t *testing.T) { require.NoError(t, err)
assert.Equal(t, tt.wantWarn, warnIfHostHasPath(tt.host))
}) 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)
// 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")
}
func TestNamespacedStore_HierarchicalLookup(t *testing.T) {
t.Parallel()
inner := &memCredStore{creds: map[string]credentials.Credential{}}
ctx := context.Background()
// Store a credential under "localhost:5000/org".
_ = inner.Put(ctx, "localhost:5000/org", credentials.Credential{Username: "orguser"})
// Lookup for a deeper path should find it.
ns := &namespacedStore{inner: inner, repository: "org/repo"}
cred, err := ns.Get(ctx, "localhost:5000")
require.NoError(t, err)
require.Equal(t, "orguser", cred.Username)
// Lookup for an unrelated path should NOT find it.
ns2 := &namespacedStore{inner: inner, repository: "other/repo"}
cred, err = ns2.Get(ctx, "localhost:5000")
require.NoError(t, err)
require.Empty(t, cred.Username)
}
// memCredStore is a simple in-memory credentials.Store for testing.
type memCredStore struct {
creds map[string]credentials.Credential
}
func (m *memCredStore) Get(_ context.Context, serverAddress string) (credentials.Credential, error) {
return m.creds[serverAddress], nil
}
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_WithRegistriesConfigPath(t *testing.T) {
t.Parallel()
credFile := filepath.Join(t.TempDir(), "config.json")
c, err := NewClient(
ClientOptWriter(io.Discard),
ClientOptCredentialsFile(credFile),
withRegistriesConfigPath("/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),
withRegistriesConfigPath(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)
}
func TestRegistryAuthorizer_UsedInLegacyPath(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
}
// 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 out io.Writer
credentialsFile string
username string
password string
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, out: client.out,
credentialsFile: client.credentialsFile,
username: client.username,
password: client.password,
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