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/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)
+	}
+}