osbase/oci/registry: add package
This adds the registry package, which contains a client and server
implementation of the OCI Distribution spec.
Change-Id: I080bb1dbc511f8e6466ca370b090d459d2b730e8
Reviewed-on: https://review.monogon.dev/c/monogon/+/4086
Tested-by: Jenkins CI
Reviewed-by: Tim Windelschmidt <tim@monogon.tech>
diff --git a/osbase/oci/registry/BUILD.bazel b/osbase/oci/registry/BUILD.bazel
new file mode 100644
index 0000000..ca2d806
--- /dev/null
+++ b/osbase/oci/registry/BUILD.bazel
@@ -0,0 +1,25 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+ name = "registry",
+ srcs = [
+ "auth.go",
+ "client.go",
+ "headers.go",
+ "server.go",
+ ],
+ importpath = "source.monogon.dev/osbase/oci/registry",
+ visibility = ["//visibility:public"],
+ deps = [
+ "//osbase/oci",
+ "//osbase/structfs",
+ "@com_github_cenkalti_backoff_v4//:backoff",
+ "@com_github_opencontainers_image_spec//specs-go/v1:specs-go",
+ ],
+)
+
+go_test(
+ name = "registry_test",
+ srcs = ["headers_test.go"],
+ embed = [":registry"],
+)
diff --git a/osbase/oci/registry/auth.go b/osbase/oci/registry/auth.go
new file mode 100644
index 0000000..a1e98aa
--- /dev/null
+++ b/osbase/oci/registry/auth.go
@@ -0,0 +1,113 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package registry
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+
+ "github.com/cenkalti/backoff/v4"
+)
+
+type tokenBody struct {
+ Token string `json:"token"`
+ AccessToken string `json:"access_token"`
+}
+
+// handleUnauthorized implements token authentication based on this
+// specification: https://distribution.github.io/distribution/spec/auth/token/
+//
+// The registry will return Unauthorized if a token is required and it is
+// missing or invalid (e.g. expired). We then need to ask the authorization
+// service for a token, and retry the original request with the new token.
+//
+// Some registries (e.g. Docker Hub and ghcr.io) require a token even for public
+// repositories. In this case, the authorization service returns tokens without
+// requiring any credentials.
+func (c *Client) handleUnauthorized(ctx context.Context, resp *http.Response) (retry bool, err error) {
+ // Check if we have a Bearer challenge.
+ challenges := parseAuthenticateHeader(resp.Header.Values("Www-Authenticate"))
+ var params map[string]string
+ for _, c := range challenges {
+ if strings.EqualFold(c.scheme, "bearer") {
+ params = c.params
+ break
+ }
+ }
+ realm := params["realm"]
+ if realm == "" {
+ // There is no challenge, return the original HTTP error.
+ return false, nil
+ }
+
+ // Construct token URL.
+ tokenURL, err := url.Parse(realm)
+ if err != nil {
+ return false, backoff.Permanent(fmt.Errorf("failed to parse realm: %w", err))
+ }
+ query := tokenURL.Query()
+ service := params["service"]
+ if service != "" {
+ query.Set("service", service)
+ }
+ for scope := range strings.SplitSeq(params["scope"], " ") {
+ if scope != "" {
+ query.Add("scope", scope)
+ }
+ }
+ tokenURL.RawQuery = query.Encode()
+
+ // Do token request.
+ req, err := http.NewRequestWithContext(ctx, "GET", tokenURL.String(), nil)
+ if err != nil {
+ return false, err
+ }
+ if c.UserAgent != "" {
+ req.Header.Set("User-Agent", c.UserAgent)
+ }
+ client := http.Client{Transport: c.Transport}
+ tokenResp, err := client.Do(req)
+ if err != nil {
+ return false, redactURLError(err)
+ }
+ if tokenResp.StatusCode != http.StatusOK {
+ return false, readClientError(tokenResp, req)
+ }
+ defer tokenResp.Body.Close()
+
+ // Parse token response.
+ bodyBytes, err := readFullBody(tokenResp, 1024*1024)
+ if err != nil {
+ return false, err
+ }
+ body := tokenBody{}
+ err = json.Unmarshal(bodyBytes, &body)
+ if err != nil {
+ return false, backoff.Permanent(fmt.Errorf("failed to parse token response: %w", err))
+ }
+ token := body.Token
+ if token == "" {
+ token = body.AccessToken
+ }
+ if token == "" {
+ return false, backoff.Permanent(fmt.Errorf("missing token in token response"))
+ }
+
+ c.authMu.Lock()
+ c.bearerToken = token
+ c.authMu.Unlock()
+ return true, nil
+}
+
+func (c *Client) addAuthorization(req *http.Request) {
+ c.authMu.RLock()
+ defer c.authMu.RUnlock()
+ if c.bearerToken != "" {
+ req.Header.Set("Authorization", "Bearer "+c.bearerToken)
+ }
+}
diff --git a/osbase/oci/registry/client.go b/osbase/oci/registry/client.go
new file mode 100644
index 0000000..c414108
--- /dev/null
+++ b/osbase/oci/registry/client.go
@@ -0,0 +1,482 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+// Package registry contains a client and server implementation of the OCI
+// Distribution spec. Both client and server only support pulling. The server is
+// intended for use in tests.
+package registry
+
+import (
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+ "net/url"
+ "regexp"
+ "strconv"
+ "strings"
+ "sync"
+ "sync/atomic"
+ "time"
+
+ "github.com/cenkalti/backoff/v4"
+ ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+
+ "source.monogon.dev/osbase/oci"
+)
+
+// Sources for these expressions:
+//
+// - https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests
+// - https://github.com/opencontainers/image-spec/blob/main/descriptor.md#digests
+const (
+ repositoryExpr = `[a-z0-9]+(?:(?:\.|_|__|-+)[a-z0-9]+)*(?:\/[a-z0-9]+(?:(?:\.|_|__|-+)[a-z0-9]+)*)*`
+ tagExpr = `[a-zA-Z0-9_][a-zA-Z0-9._-]{0,127}`
+ digestExpr = `[a-z0-9]+(?:[+._-][a-z0-9]+)*:[a-zA-Z0-9=_-]+`
+)
+
+var (
+ repositoryRegexp = regexp.MustCompile(`^` + repositoryExpr + `$`)
+ tagRegexp = regexp.MustCompile(`^` + tagExpr + `$`)
+ digestRegexp = regexp.MustCompile(`^` + digestExpr + `$`)
+)
+
+// Client is an OCI registry client.
+type Client struct {
+ // Transport will be used to make requests. For example, this allows
+ // configuring TLS client and CA certificates.
+ // If nil, [http.DefaultTransport] is used.
+ Transport http.RoundTripper
+ // GetBackOff can be set to to make the Client retry HTTP requests.
+ GetBackOff func() backoff.BackOff
+ // RetryNotify receives errors that trigger a retry, e.g. for logging.
+ RetryNotify backoff.Notify
+ // UserAgent is used as the User-Agent HTTP header.
+ UserAgent string
+
+ // Scheme must be either http or https.
+ Scheme string
+ // Host is the host with optional port.
+ Host string
+ // Repository is the name of the repository. It is part of the client because
+ // bearer tokens are usually scoped to a repository.
+ Repository string
+
+ authMu sync.RWMutex
+ // bearerToken is a cached token obtained from an authorization service.
+ bearerToken string
+}
+
+// Read fetches an image manifest from the registry and returns an [oci.Image].
+//
+// The context is used for the manifest request and all blob requests made
+// through the Image.
+//
+// At least one of tag and digest must be set. If only tag is set, then you are
+// trusting the registry to return the right content. Otherwise, the digest is
+// used to verify the manifest. If both tag and digest are set, then the tag is
+// used in the request, and the digest is used to verify the response. The
+// advantage of fetching by tag is that it allows a pull through cache to
+// display tags to a user inspecting the cache contents.
+func (c *Client) Read(ctx context.Context, tag, digest string) (*oci.Image, error) {
+ if !repositoryRegexp.MatchString(c.Repository) {
+ return nil, fmt.Errorf("invalid repository %q", c.Repository)
+ }
+ if tag != "" && !tagRegexp.MatchString(tag) {
+ return nil, fmt.Errorf("invalid tag %q", tag)
+ }
+ if digest != "" {
+ if _, _, err := oci.ParseDigest(digest); err != nil {
+ return nil, err
+ }
+ }
+ var reference string
+ if tag != "" {
+ reference = tag
+ } else if digest != "" {
+ reference = digest
+ } else {
+ return nil, fmt.Errorf("tag and digest cannot both be empty")
+ }
+
+ manifestPath := fmt.Sprintf("/v2/%s/manifests/%s", c.Repository, reference)
+ var imageManifestBytes []byte
+ err := c.retry(ctx, func() error {
+ req, err := c.newGet(manifestPath)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Accept", ocispecv1.MediaTypeImageManifest)
+ resp, err := c.doGet(ctx, req)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return readClientError(resp, req)
+ }
+ defer resp.Body.Close()
+ imageManifestBytes, err = readFullBody(resp, 50*1024*1024)
+ return err
+ })
+ if err != nil {
+ return nil, err
+ }
+
+ blobs := &clientBlobs{
+ ctx: ctx,
+ client: c,
+ }
+ return oci.NewImage(imageManifestBytes, digest, blobs)
+}
+
+type clientBlobs struct {
+ ctx context.Context
+ client *Client
+}
+
+func (r *clientBlobs) Blob(descriptor *ocispecv1.Descriptor) (io.ReadCloser, error) {
+ if !digestRegexp.MatchString(string(descriptor.Digest)) {
+ return nil, fmt.Errorf("invalid blob digest %q", descriptor.Digest)
+ }
+ blobPath := fmt.Sprintf("/v2/%s/blobs/%s", r.client.Repository, descriptor.Digest)
+ var resp *http.Response
+ err := r.client.retry(r.ctx, func() error {
+ req, err := r.client.newGet(blobPath)
+ if err != nil {
+ return err
+ }
+ resp, err = r.client.doGet(r.ctx, req)
+ if err != nil {
+ return err
+ }
+ if resp.StatusCode != http.StatusOK {
+ return readClientError(resp, req)
+ }
+ return nil
+ })
+ if err != nil {
+ return nil, err
+ }
+ if r.client.GetBackOff == nil {
+ return resp.Body, nil
+ }
+ ctx, cancel := context.WithCancelCause(r.ctx)
+ reader := &retryReader{
+ ctx: ctx,
+ cancel: cancel,
+ client: r.client,
+ path: blobPath,
+ pos: 0,
+ size: descriptor.Size,
+ }
+ reader.resp.Store(resp)
+ return reader, nil
+}
+
+type retryReader struct {
+ ctx context.Context
+ cancel context.CancelCauseFunc
+ client *Client
+ path string
+ pos int64
+ size int64
+ // resp is an atomic pointer because it may be concurrently written by Read()
+ // and read by Close().
+ resp atomic.Pointer[http.Response]
+}
+
+func (r *retryReader) Read(p []byte) (n int, err error) {
+ if r.pos >= r.size {
+ return 0, io.EOF
+ }
+ if len(p) == 0 {
+ return 0, nil
+ }
+ if int64(len(p)) > r.size-r.pos {
+ p = p[:r.size-r.pos]
+ }
+ closed := false
+ err = r.client.retry(r.ctx, func() error {
+ if closed {
+ req, err := r.client.newGet(r.path)
+ if err != nil {
+ return err
+ }
+ if r.pos != 0 {
+ req.Header.Set("Range", fmt.Sprintf("bytes=%d-", r.pos))
+ }
+ resp, err := r.client.doGet(r.ctx, req)
+ if err != nil {
+ return err
+ }
+ r.resp.Store(resp)
+ if err := context.Cause(r.ctx); err != nil {
+ resp.Body.Close()
+ return err
+ }
+ switch resp.StatusCode {
+ case http.StatusOK:
+ _, err := io.CopyN(io.Discard, resp.Body, r.pos)
+ if err != nil {
+ return err
+ }
+ case http.StatusPartialContent:
+ if !strings.HasPrefix(resp.Header.Get("Content-Range"), fmt.Sprintf("bytes %d-", r.pos)) {
+ return backoff.Permanent(errors.New("invalid content range"))
+ }
+ default:
+ return readClientError(resp, req)
+ }
+ }
+ var err error
+ n, err = r.resp.Load().Body.Read(p)
+ if n != 0 {
+ r.pos += int64(n)
+ return nil
+ }
+ if err == nil {
+ err = errors.New("read 0 bytes")
+ }
+ closed = true
+ r.resp.Load().Body.Close()
+ return err
+ })
+ if r.pos >= r.size {
+ err = io.EOF
+ } else if err == io.EOF {
+ err = io.ErrUnexpectedEOF
+ }
+ return
+}
+
+func (r *retryReader) Close() error {
+ r.cancel(errors.New("reader closed"))
+ return r.resp.Load().Body.Close()
+}
+
+func (c *Client) retry(ctx context.Context, o func() error) error {
+ if err := ctx.Err(); err != nil {
+ return err
+ }
+ var b backoff.BackOff
+ for {
+ err := o()
+ if err == nil {
+ return nil
+ }
+ var permanent *backoff.PermanentError
+ if errors.As(err, &permanent) {
+ return err
+ }
+ if ctx.Err() != nil {
+ return err
+ }
+ if b == nil {
+ if c.GetBackOff == nil {
+ return err
+ }
+ b = c.GetBackOff()
+ }
+ next := b.NextBackOff()
+ if next == backoff.Stop {
+ return err
+ }
+ var clientErr *ClientError
+ if errors.As(err, &clientErr) && !clientErr.RetryAfter.IsZero() {
+ next = max(next, time.Until(clientErr.RetryAfter))
+ }
+ deadline, hasDeadline := ctx.Deadline()
+ if hasDeadline && time.Until(deadline) < next {
+ return err
+ }
+
+ if c.RetryNotify != nil {
+ c.RetryNotify(err, next)
+ }
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-time.After(next):
+ }
+ }
+}
+
+func (c *Client) newGet(path string) (*http.Request, error) {
+ u := url.URL{
+ Scheme: c.Scheme,
+ Host: c.Host,
+ Path: path,
+ }
+ req, err := http.NewRequest("GET", u.String(), nil)
+ if err != nil {
+ return nil, err
+ }
+ if c.UserAgent != "" {
+ req.Header.Set("User-Agent", c.UserAgent)
+ }
+ return req, nil
+}
+
+func (c *Client) doGet(ctx context.Context, req *http.Request) (*http.Response, error) {
+ req = req.WithContext(ctx)
+ c.addAuthorization(req)
+ client := http.Client{Transport: c.Transport}
+ resp, err := client.Do(req)
+ if err != nil {
+ return nil, redactURLError(err)
+ }
+
+ if resp.StatusCode == http.StatusUnauthorized {
+ unauthorizedErr := readClientError(resp, req)
+ retry, err := c.handleUnauthorized(ctx, resp)
+ if err != nil {
+ return nil, err
+ }
+ if !retry {
+ return nil, unauthorizedErr
+ }
+ c.addAuthorization(req)
+ resp, err = client.Do(req)
+ if err != nil {
+ return nil, redactURLError(err)
+ }
+ }
+
+ return resp, nil
+}
+
+func readClientError(resp *http.Response, req *http.Request) error {
+ defer resp.Body.Close()
+ clientErr := &ClientError{
+ StatusCode: resp.StatusCode,
+ }
+ retryAfter := resp.Header.Get("Retry-After")
+ if retryAfter != "" {
+ seconds, err := strconv.ParseInt(retryAfter, 10, 64)
+ if err == nil {
+ clientErr.RetryAfter = time.Now().Add(time.Duration(seconds) * time.Second)
+ } else {
+ clientErr.RetryAfter, _ = http.ParseTime(retryAfter)
+ }
+ }
+ content, err := readFullBody(resp, 2048)
+ if err == nil {
+ clientErr.RawBody = content
+ _ = json.Unmarshal(content, &clientErr.ErrorBody)
+ }
+
+ errReq := resp.Request
+ if errReq == nil {
+ errReq = req
+ }
+ urlErr := &url.Error{
+ Op: errReq.Method,
+ URL: errReq.URL.Redacted(),
+ Err: clientErr,
+ }
+ err = redactURLError(urlErr)
+
+ // Client errors are usually permanent, and server errors are usually
+ // temporary, but there are some exceptions.
+ isTemporary := 500 <= clientErr.StatusCode && clientErr.StatusCode <= 599
+ switch clientErr.StatusCode {
+ case http.StatusRequestTimeout, http.StatusTooEarly,
+ http.StatusTooManyRequests,
+ 499: // nginx-specific, client closed request
+ isTemporary = true
+ case http.StatusNotImplemented, http.StatusHTTPVersionNotSupported,
+ http.StatusNetworkAuthenticationRequired:
+ isTemporary = false
+ }
+ if !isTemporary {
+ return backoff.Permanent(err)
+ }
+ return err
+}
+
+// ClientError is an HTTP error received from a registry or authorization
+// service.
+type ClientError struct {
+ ErrorBody
+ StatusCode int
+ RetryAfter time.Time
+ RawBody []byte
+}
+
+type ErrorBody struct {
+ Errors []ErrorInfo `json:"errors,omitempty"`
+}
+
+type ErrorInfo struct {
+ Code string `json:"code"`
+ Message string `json:"message,omitempty"`
+}
+
+func (e *ClientError) Error() string {
+ if len(e.Errors) == 0 {
+ text := fmt.Sprintf("HTTP %d %s", e.StatusCode, http.StatusText(e.StatusCode))
+ if len(e.RawBody) != 0 {
+ text = fmt.Sprintf("%s: %q", text, e.RawBody)
+ }
+ return text
+ }
+ var errorStrs []string
+ for _, ei := range e.Errors {
+ errorStrs = append(errorStrs, fmt.Sprintf("%s: %s", ei.Code, ei.Message))
+ }
+ return fmt.Sprintf("HTTP %d %s", e.StatusCode, strings.Join(errorStrs, "; "))
+}
+
+// redactURLError redacts the URL in an [url.Error]. After redirects, the URL
+// may contain secrets in query parameter values.
+//
+// Logic adapted from:
+// https://github.com/google/go-containerregistry/blob/v0.20.3/internal/redact/redact.go
+func redactURLError(err error) error {
+ var urlErr *url.Error
+ if !errors.As(err, &urlErr) {
+ return err
+ }
+ u, perr := url.Parse(urlErr.URL)
+ if perr != nil {
+ return err
+ }
+ query := u.Query()
+ for name, vals := range query {
+ if name == "scope" || name == "service" {
+ continue
+ }
+ for i := range vals {
+ vals[i] = "REDACTED"
+ }
+ }
+ u.RawQuery = query.Encode()
+ urlErr.URL = u.Redacted()
+ return err
+}
+
+func readFullBody(resp *http.Response, limit int) ([]byte, error) {
+ switch {
+ case resp.ContentLength < 0:
+ lr := io.LimitReader(resp.Body, int64(limit)+1)
+ content, err := io.ReadAll(lr)
+ if err != nil {
+ return nil, err
+ }
+ if len(content) > limit {
+ return nil, backoff.Permanent(fmt.Errorf("HTTP response exceeds limit of %d bytes", limit))
+ }
+ return content, nil
+ case resp.ContentLength <= int64(limit):
+ content := make([]byte, resp.ContentLength)
+ _, err := io.ReadFull(resp.Body, content)
+ if err != nil {
+ return nil, err
+ }
+ return content, nil
+ default:
+ return nil, backoff.Permanent(fmt.Errorf("HTTP response of size %d exceeds limit of %d bytes", resp.ContentLength, limit))
+ }
+}
diff --git a/osbase/oci/registry/headers.go b/osbase/oci/registry/headers.go
new file mode 100644
index 0000000..3d1de2f
--- /dev/null
+++ b/osbase/oci/registry/headers.go
@@ -0,0 +1,179 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package registry
+
+import "strings"
+
+type authenticateChallenge struct {
+ scheme string
+ info string
+ params map[string]string
+}
+
+// parseAuthenticateHeader parses the values of a WWW-Authenticate HTTP header.
+// parameter names are converted to lower case.
+// If any value fails to parse, it returns nil.
+func parseAuthenticateHeader(authenticate []string) []authenticateChallenge {
+ // From RFC 9110:
+ // WWW-Authenticate = #challenge
+ // challenge = auth-scheme [ 1*SP ( token68 / #auth-param ) ]
+ // auth-scheme = token
+ // token68 = 1*( ALPHA / DIGIT / "-" / "." / "_" / "~" / "+" / "/" ) *"="
+ // auth-param = token BWS "=" BWS ( token / quoted-string )
+ // #element => [ element ] *( OWS "," OWS [ element ] )
+ // token = 1*tchar
+ // tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*"
+ // / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~"
+ // / DIGIT / ALPHA
+ // quoted-string = DQUOTE *( qdtext / quoted-pair ) DQUOTE
+ // qdtext = HTAB / SP / %x21 / %x23-5B / %x5D-7E / obs-text
+ // quoted-pair = "\" ( HTAB / SP / VCHAR / obs-text )
+ // obs-text = %x80-FF
+ // OWS = *( SP / HTAB )
+ // BWS = OWS
+ // VCHAR = %x21-7E
+
+ var challenges []authenticateChallenge
+ for _, a := range authenticate {
+ for {
+ a = strings.TrimLeft(a, " \t,") // Consume commas and OWS
+ if a == "" {
+ break
+ }
+ var scheme string
+ scheme, a = scanToken(a) // Consume auth-scheme
+ if scheme == "" {
+ return nil
+ }
+ challenge := authenticateChallenge{
+ scheme: scheme,
+ }
+ if !strings.HasPrefix(a, " ") { // Check for 1*SP
+ a = strings.TrimLeft(a, " \t") // Consume OWS
+ if a != "" && a[0] != ',' { // Check for mandatory comma
+ return nil
+ }
+ challenges = append(challenges, challenge)
+ continue
+ }
+ a = strings.TrimLeft(a, " ") // Consume 1*SP
+
+ // Check for token68
+ i := 0
+ for i < len(a) && charType[a[i]]&charTypeToken68 != 0 {
+ i++
+ }
+ if i != 0 {
+ for i < len(a) && a[i] == '=' { // Consume *"="
+ i++
+ }
+ remain := strings.TrimLeft(a[i:], " \t") // Consume OWS
+ if remain == "" || remain[0] == ',' { // Check for mandatory comma
+ // Confirmed token68
+ challenge.info = a[:i]
+ challenges = append(challenges, challenge)
+ a = remain
+ continue
+ }
+ }
+
+ challenge.params = make(map[string]string)
+ for {
+ // Check for auth-param
+ remain := strings.TrimLeft(a, " \t,") // Consume commas and OWS
+ var name string
+ name, remain = scanToken(remain) // Consume token
+ if name == "" {
+ break
+ }
+ remain = strings.TrimLeft(remain, " \t") // Consume BWS
+ var ok bool
+ if remain, ok = strings.CutPrefix(remain, "="); !ok { // Consume "="
+ break
+ }
+ remain = strings.TrimLeft(remain, " \t") // Consume BWS
+ var value string
+ if remain, ok = strings.CutPrefix(remain, `"`); ok { // Check for quoted-string
+ i := 0
+ for i < len(remain) {
+ if charType[remain[i]]&charTypeQdtext != 0 {
+ i++
+ } else if remain[i] == '\\' && i+1 < len(remain) && charType[remain[i+1]]&charTypeQuotedPair != 0 {
+ value += remain[:i]
+ remain = remain[i+1:] // Drop the backslash to unescape the string
+ i = 1
+ } else {
+ break
+ }
+ }
+ value += remain[:i]
+ remain = remain[i:]
+ if remain, ok = strings.CutPrefix(remain, `"`); !ok { // Consume quote
+ break
+ }
+ } else {
+ value, remain = scanToken(remain) // Consume token
+ if value == "" {
+ break
+ }
+ }
+ // Confirmed auth-param
+ name = strings.ToLower(name) // name is case-insensitive
+ if _, ok := challenge.params[name]; ok {
+ return nil // each parameter name MUST only occur once
+ }
+ challenge.params[name] = value
+ a = remain
+ a = strings.TrimLeft(a, " \t") // Consume OWS
+ if a != "" && a[0] != ',' { // Check for mandatory comma
+ return nil
+ }
+ }
+ challenges = append(challenges, challenge)
+ a = strings.TrimLeft(a, " \t") // Consume OWS
+ if a != "" && a[0] != ',' { // Check for mandatory comma
+ return nil
+ }
+ }
+ }
+ return challenges
+}
+
+var charType [256]uint8
+
+const (
+ charTypeToken = 1 << iota
+ charTypeToken68
+ charTypeQdtext
+ charTypeQuotedPair
+)
+
+func init() {
+ for _, c := range "!#$%&'*+-.^_`|~" {
+ charType[c] |= charTypeToken
+ }
+ for _, c := range "-._~+/" {
+ charType[c] |= charTypeToken68
+ }
+ for c := range 256 {
+ if '0' <= c && c <= '9' || 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' {
+ charType[c] |= charTypeToken | charTypeToken68
+ }
+ if c == '\t' || c == ' ' || 0x21 <= c && c != 0x7f {
+ if c != '"' && c != '\\' {
+ charType[c] |= charTypeQdtext
+ }
+ charType[c] |= charTypeQuotedPair
+ }
+ }
+}
+
+func scanToken(s string) (token string, remain string) {
+ for i := range len(s) {
+ if charType[s[i]]&charTypeToken == 0 {
+ return s[:i], s[i:]
+ }
+ }
+ return s, ""
+}
diff --git a/osbase/oci/registry/headers_test.go b/osbase/oci/registry/headers_test.go
new file mode 100644
index 0000000..f883ea9
--- /dev/null
+++ b/osbase/oci/registry/headers_test.go
@@ -0,0 +1,83 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package registry
+
+import "testing"
+
+func TestParseAuthenticateHeader(t *testing.T) {
+ testCases := []struct {
+ desc string
+ header []string
+ parsed []authenticateChallenge
+ }{
+ {"absent", nil, nil},
+ {"no params",
+ []string{"Basic, !#$%&'*+-.^_`|~019abzABZ"},
+ []authenticateChallenge{{scheme: "Basic"}, {scheme: "!#$%&'*+-.^_`|~019abzABZ"}}},
+ {"token68",
+ []string{"0 a", "1 abzABZ019-._~+/, 2 abc=, 3 a==="},
+ []authenticateChallenge{
+ {scheme: "0", info: "a"},
+ {scheme: "1", info: "abzABZ019-._~+/"},
+ {scheme: "2", info: "abc="},
+ {scheme: "3", info: "a==="},
+ }},
+ {"params",
+ []string{`0 a="=,", empty = "", escape="\a\\\"", ` + "1 token!#$%&'*+-.^_`|~019abzABZ=!#$%&'*+-.^_`|~019abzABZ"},
+ []authenticateChallenge{
+ {scheme: "0", params: map[string]string{"a": "=,", "empty": "", "escape": `a\"`}},
+ {scheme: "1", params: map[string]string{"token!#$%&'*+-.^_`|~019abzabz": "!#$%&'*+-.^_`|~019abzABZ"}},
+ }},
+ {"duplicate param", []string{`Basic realm="apps", REALM=other`}, nil},
+ {"empty", []string{"", " ", "\t", ",", " , ,,\t ,", "Basic"}, []authenticateChallenge{{scheme: "Basic"}}},
+ {"RFC example",
+ []string{`Basic realm="simple", Newauth realm="apps", type=1, title="Login to \"apps\""`},
+ []authenticateChallenge{
+ {scheme: "Basic", params: map[string]string{"realm": "simple"}},
+ {scheme: "Newauth", params: map[string]string{"realm": "apps", "type": "1", "title": `Login to "apps"`}},
+ }},
+ {"extra commas",
+ []string{` , , Basic , , realm="simple" , , Newauth ,realm="apps",type=1` + "\t" + `, ,title="Login to \"apps\"" , , `},
+ []authenticateChallenge{
+ {scheme: "Basic", params: map[string]string{"realm": "simple"}},
+ {scheme: "Newauth", params: map[string]string{"realm": "apps", "type": "1", "title": `Login to "apps"`}},
+ }},
+ {"missing comma between challenges", []string{"Basic\tBearer"}, nil},
+ {"missing comma between challenges 2", []string{"Basic !"}, nil},
+ {"missing comma after token68", []string{"Basic a Bearer"}, nil},
+ {"missing comma between params", []string{`Basic realm="simple" type=1`}, nil},
+ {"missing quote", []string{`Basic realm="simple`}, nil},
+ {"missing value", []string{`Basic !=`}, nil},
+ }
+ for _, tC := range testCases {
+ t.Run(tC.desc, func(t *testing.T) {
+ actual := parseAuthenticateHeader(tC.header)
+ if want, got := len(tC.parsed), len(actual); want != got {
+ t.Fatalf("Expected %d challenges, got %d", want, got)
+ }
+ for i, actualC := range actual {
+ wantC := tC.parsed[i]
+ if want, got := wantC.scheme, actualC.scheme; want != got {
+ t.Errorf("Expected scheme %q, got %q", want, got)
+ }
+ if want, got := wantC.info, actualC.info; want != got {
+ t.Errorf("Expected info %q, got %q", want, got)
+ }
+ for param, want := range wantC.params {
+ got, ok := actualC.params[param]
+ if !ok {
+ t.Errorf("Scheme %s: Missing param %q", wantC.scheme, param)
+ } else if want != got {
+ t.Errorf("Scheme %s: Expected %s=%q, got %q", wantC.scheme, param, want, got)
+ }
+ }
+ for param := range actualC.params {
+ if _, ok := wantC.params[param]; !ok {
+ t.Errorf("Scheme %s: Extra param %q", wantC.scheme, param)
+ }
+ }
+ }
+ })
+ }
+}
diff --git a/osbase/oci/registry/server.go b/osbase/oci/registry/server.go
new file mode 100644
index 0000000..13a9dc2
--- /dev/null
+++ b/osbase/oci/registry/server.go
@@ -0,0 +1,181 @@
+// Copyright The Monogon Project Authors.
+// SPDX-License-Identifier: Apache-2.0
+
+package registry
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "internal/sync"
+ "io"
+ "net/http"
+ "regexp"
+ "strconv"
+ "strings"
+ "time"
+
+ ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
+
+ "source.monogon.dev/osbase/oci"
+ "source.monogon.dev/osbase/structfs"
+)
+
+var (
+ manifestsEp = regexp.MustCompile("^/v2/(" + repositoryExpr + ")/manifests/(" + tagExpr + "|" + digestExpr + ")$")
+ blobsEp = regexp.MustCompile("^/v2/(" + repositoryExpr + ")/blobs/(" + digestExpr + ")$")
+)
+
+// Server is an OCI registry server.
+type Server struct {
+ mu sync.Mutex
+ repositories map[string]*serverRepository
+}
+
+type serverRepository struct {
+ tags map[string]string
+ manifests map[string]serverManifest
+ blobs map[string]structfs.Blob
+}
+
+type serverManifest struct {
+ contentType string
+ content []byte
+}
+
+func NewServer() *Server {
+ return &Server{
+ repositories: make(map[string]*serverRepository),
+ }
+}
+
+// AddImage adds an image to the server in the specified repository.
+//
+// If the tag is empty, the image can only be fetched by digest.
+func (s *Server) AddImage(repository string, tag string, image *oci.Image) error {
+ if !repositoryRegexp.MatchString(repository) {
+ return fmt.Errorf("invalid repository %q", repository)
+ }
+ if tag != "" && !tagRegexp.MatchString(tag) {
+ return fmt.Errorf("invalid tag %q", tag)
+ }
+
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ repo := s.repositories[repository]
+ if repo == nil {
+ repo = &serverRepository{
+ tags: make(map[string]string),
+ manifests: make(map[string]serverManifest),
+ blobs: make(map[string]structfs.Blob),
+ }
+ s.repositories[repository] = repo
+ }
+ if _, ok := repo.manifests[image.ManifestDigest]; !ok {
+ for descriptor := range image.Descriptors() {
+ repo.blobs[string(descriptor.Digest)] = image.StructfsBlob(descriptor)
+ }
+ repo.manifests[image.ManifestDigest] = serverManifest{
+ contentType: ocispecv1.MediaTypeImageManifest,
+ content: image.RawManifest,
+ }
+ }
+ if tag != "" {
+ repo.tags[tag] = image.ManifestDigest
+ }
+ return nil
+}
+
+func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
+ if req.Method != "GET" && req.Method != "HEAD" {
+ http.Error(w, "Registry is read-only, only GET and HEAD are allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ if req.URL.Path == "/v2/" {
+ w.WriteHeader(http.StatusOK)
+ } else if matches := manifestsEp.FindStringSubmatch(req.URL.Path); len(matches) > 0 {
+ repository := matches[1]
+ reference := matches[2]
+ s.mu.Lock()
+ repo := s.repositories[repository]
+ if repo == nil {
+ s.mu.Unlock()
+ serveError(w, "NAME_UNKNOWN", fmt.Sprintf("Unknown repository: %s", repository), http.StatusNotFound)
+ return
+ }
+ digest := reference
+ if !strings.ContainsRune(reference, ':') {
+ var ok bool
+ digest, ok = repo.tags[reference]
+ if !ok {
+ s.mu.Unlock()
+ serveError(w, "MANIFEST_UNKNOWN", fmt.Sprintf("Unknown tag: %s", reference), http.StatusNotFound)
+ return
+ }
+ }
+ manifest, ok := repo.manifests[digest]
+ s.mu.Unlock()
+ if !ok {
+ serveError(w, "MANIFEST_UNKNOWN", fmt.Sprintf("Unknown manifest: %s", digest), http.StatusNotFound)
+ return
+ }
+
+ w.Header().Set("Docker-Content-Digest", digest)
+ w.Header().Set("Etag", fmt.Sprintf(`"%s"`, digest))
+ w.Header().Set("Content-Type", manifest.contentType)
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ http.ServeContent(w, req, "", time.Time{}, bytes.NewReader(manifest.content))
+ } else if matches := blobsEp.FindStringSubmatch(req.URL.Path); len(matches) > 0 {
+ repository := matches[1]
+ digest := matches[2]
+ s.mu.Lock()
+ repo := s.repositories[repository]
+ if repo == nil {
+ s.mu.Unlock()
+ serveError(w, "NAME_UNKNOWN", fmt.Sprintf("Unknown repository: %s", repository), http.StatusNotFound)
+ return
+ }
+ blob, ok := repo.blobs[digest]
+ s.mu.Unlock()
+ if !ok {
+ serveError(w, "BLOB_UNKNOWN", fmt.Sprintf("Unknown blob: %s", digest), http.StatusNotFound)
+ return
+ }
+
+ content, err := blob.Open()
+ if err != nil {
+ http.Error(w, "Failed to open blob", http.StatusInternalServerError)
+ return
+ }
+ defer content.Close()
+ w.Header().Set("Docker-Content-Digest", digest)
+ w.Header().Set("Etag", fmt.Sprintf(`"%s"`, digest))
+ w.Header().Set("Content-Type", "application/octet-stream")
+ if contentSeeker, ok := content.(io.ReadSeeker); ok {
+ http.ServeContent(w, req, "", time.Time{}, contentSeeker)
+ } else {
+ // Range requests are not supported.
+ w.Header().Set("Content-Length", strconv.FormatInt(blob.Size(), 10))
+ w.WriteHeader(http.StatusOK)
+ if req.Method != "HEAD" {
+ io.CopyN(w, content, blob.Size())
+ }
+ }
+ } else {
+ w.WriteHeader(http.StatusNotFound)
+ }
+}
+
+func serveError(w http.ResponseWriter, code string, message string, statusCode int) {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ w.Header().Set("X-Content-Type-Options", "nosniff")
+ w.WriteHeader(statusCode)
+ content, err := json.Marshal(&ErrorBody{Errors: []ErrorInfo{{
+ Code: code,
+ Message: message,
+ }}})
+ if err == nil {
+ w.Write(content)
+ }
+}