// Package registry looks up the digest a tag points at in a container // registry and lists the tags of a repository. Manifests are only ever // requested with HEAD. package registry import ( "context" "errors" "fmt" "log/slog" "net" "net/http" "net/url" "regexp" "strings" "github.com/google/go-containerregistry/pkg/authn" "github.com/google/go-containerregistry/pkg/name" "github.com/google/go-containerregistry/pkg/v1/remote" "github.com/google/go-containerregistry/pkg/v1/remote/transport" "github.com/shcizo/package-updater/internal/imageref" ) // Client looks up tag digests and lists tags. Docker Hub counts a GET of a // manifest as a pull against the rate limit and a HEAD as nothing, so Client // never issues a manifest GET. The only GETs it sends are the /v2/ ping and // tags/list, which Docker Hub does not count as a pull. type Client struct { keychain authn.Keychain userAgent string log *slog.Logger } // New returns a Client that finds credentials the way the Docker CLI does: // in $DOCKER_CONFIG/config.json, else ~/.docker/config.json. Registries // without an entry are accessed anonymously. userAgent is added to the // User-Agent header of every request. log receives the full error of a // failed lookup, which Digest itself returns only in a sanitised form. func New(userAgent string, log *slog.Logger) *Client { return &Client{keychain: authn.DefaultKeychain, userAgent: userAgent, log: log} } // Digest returns the digest that "repo:tag" points at, as the registry // reports it: for a multi-arch image that is the digest of the index, the // same one `docker inspect` shows in RepoDigests. A digest suffix in ref is // ignored. It sends HEAD /v2//manifests/ and nothing else to the // manifest endpoint; it does not fall back to GET when the registry omits // the digest header. func (c *Client) Digest(ctx context.Context, ref string) (string, error) { p := imageref.Parse(ref) if p.Tag == "" { return "", fmt.Errorf("image %q has no tag", ref) } r, err := name.ParseReference(p.Repo + ":" + p.Tag) if err != nil { return "", fmt.Errorf("parse %s: %w", ref, err) } // Credentials are resolved here, not by ggcr, so that a broken credential // helper keeps its own message: that text comes from the local config. auth, err := c.keychain.Resolve(r.Context()) if err != nil { return "", fmt.Errorf("lookup %s: %w", r, err) } desc, err := remote.Head(r, remote.WithContext(ctx), remote.WithAuth(auth), remote.WithUserAgent(c.userAgent)) if err != nil { return "", c.lookupError("lookup", r.String(), ref, err) } return desc.Digest.String(), nil } // Tags returns every tag of repo ("host/path", or a Docker Hub name), in the // order the registry lists them. It sends GET /v2//tags/list, following // the registry's pagination links. Errors are sanitised like Digest's. func (c *Client) Tags(ctx context.Context, repo string) ([]string, error) { r, err := name.NewRepository(repo) if err != nil { return nil, fmt.Errorf("parse %s: %w", repo, err) } auth, err := c.keychain.Resolve(r) if err != nil { return nil, fmt.Errorf("list %s: %w", r, err) } tags, err := remote.List(r, remote.WithContext(ctx), remote.WithAuth(auth), remote.WithUserAgent(c.userAgent)) if err != nil { return nil, c.lookupError("list", r.String(), repo, err) } return tags, nil } // lookupError turns a failed registry request into an error that is safe to // show: a registry answer is described by its status and codes, anything // else by a fixed text. The full error goes to the log. verb and target // start the message; ref is what the log line names. func (c *Client) lookupError(verb, target, ref string, err error) error { var terr *transport.Error if errors.As(err, &terr) { return fmt.Errorf("%s %s: %s", verb, target, describe(terr)) } c.log.Warn("registry_request_failed", "ref", ref, "error", err) return fmt.Errorf("%s %s: %s", verb, target, classify(err)) } // classify returns a fixed text for an error that is not a registry answer. // ggcr copies registry-controlled text (token responses, authentication // challenges, redirect URLs, certificate names) into such errors, so none of // it is passed on. func classify(err error) string { var netErr net.Error var urlErr *url.Error switch { case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): return "timeout" case errors.As(err, &netErr), errors.As(err, &urlErr): return "cannot reach registry" } return "lookup failed" } // errorCodePattern matches the registry error codes of the distribution // spec ("MANIFEST_UNKNOWN"). Anything else is dropped from error text. var errorCodePattern = regexp.MustCompile(`^[A-Z_]{1,40}$`) // describe returns the HTTP status and the structured error codes of a // registry error, without the messages, details or raw body that ggcr copies // into its error text. The registry host comes from an image name, so // whatever it answers must not end up in the UI. func describe(e *transport.Error) string { msg := fmt.Sprintf("registry answered %d %s", e.StatusCode, http.StatusText(e.StatusCode)) var codes []string for _, d := range e.Errors { if errorCodePattern.MatchString(string(d.Code)) { codes = append(codes, string(d.Code)) } } if len(codes) > 0 { msg += " (" + strings.Join(codes, ", ") + ")" } return msg }