blob: 13a9dc238b2b574f98b10c14c8be04299a1382a1 [file] [log] [blame]
Jan Schärcc9e4d12025-04-14 10:28:40 +00001// Copyright The Monogon Project Authors.
2// SPDX-License-Identifier: Apache-2.0
3
4package registry
5
6import (
7 "bytes"
8 "encoding/json"
9 "fmt"
10 "internal/sync"
11 "io"
12 "net/http"
13 "regexp"
14 "strconv"
15 "strings"
16 "time"
17
18 ocispecv1 "github.com/opencontainers/image-spec/specs-go/v1"
19
20 "source.monogon.dev/osbase/oci"
21 "source.monogon.dev/osbase/structfs"
22)
23
24var (
25 manifestsEp = regexp.MustCompile("^/v2/(" + repositoryExpr + ")/manifests/(" + tagExpr + "|" + digestExpr + ")$")
26 blobsEp = regexp.MustCompile("^/v2/(" + repositoryExpr + ")/blobs/(" + digestExpr + ")$")
27)
28
29// Server is an OCI registry server.
30type Server struct {
31 mu sync.Mutex
32 repositories map[string]*serverRepository
33}
34
35type serverRepository struct {
36 tags map[string]string
37 manifests map[string]serverManifest
38 blobs map[string]structfs.Blob
39}
40
41type serverManifest struct {
42 contentType string
43 content []byte
44}
45
46func NewServer() *Server {
47 return &Server{
48 repositories: make(map[string]*serverRepository),
49 }
50}
51
52// AddImage adds an image to the server in the specified repository.
53//
54// If the tag is empty, the image can only be fetched by digest.
55func (s *Server) AddImage(repository string, tag string, image *oci.Image) error {
56 if !repositoryRegexp.MatchString(repository) {
57 return fmt.Errorf("invalid repository %q", repository)
58 }
59 if tag != "" && !tagRegexp.MatchString(tag) {
60 return fmt.Errorf("invalid tag %q", tag)
61 }
62
63 s.mu.Lock()
64 defer s.mu.Unlock()
65 repo := s.repositories[repository]
66 if repo == nil {
67 repo = &serverRepository{
68 tags: make(map[string]string),
69 manifests: make(map[string]serverManifest),
70 blobs: make(map[string]structfs.Blob),
71 }
72 s.repositories[repository] = repo
73 }
74 if _, ok := repo.manifests[image.ManifestDigest]; !ok {
75 for descriptor := range image.Descriptors() {
76 repo.blobs[string(descriptor.Digest)] = image.StructfsBlob(descriptor)
77 }
78 repo.manifests[image.ManifestDigest] = serverManifest{
79 contentType: ocispecv1.MediaTypeImageManifest,
80 content: image.RawManifest,
81 }
82 }
83 if tag != "" {
84 repo.tags[tag] = image.ManifestDigest
85 }
86 return nil
87}
88
89func (s *Server) ServeHTTP(w http.ResponseWriter, req *http.Request) {
90 if req.Method != "GET" && req.Method != "HEAD" {
91 http.Error(w, "Registry is read-only, only GET and HEAD are allowed", http.StatusMethodNotAllowed)
92 return
93 }
94
95 if req.URL.Path == "/v2/" {
96 w.WriteHeader(http.StatusOK)
97 } else if matches := manifestsEp.FindStringSubmatch(req.URL.Path); len(matches) > 0 {
98 repository := matches[1]
99 reference := matches[2]
100 s.mu.Lock()
101 repo := s.repositories[repository]
102 if repo == nil {
103 s.mu.Unlock()
104 serveError(w, "NAME_UNKNOWN", fmt.Sprintf("Unknown repository: %s", repository), http.StatusNotFound)
105 return
106 }
107 digest := reference
108 if !strings.ContainsRune(reference, ':') {
109 var ok bool
110 digest, ok = repo.tags[reference]
111 if !ok {
112 s.mu.Unlock()
113 serveError(w, "MANIFEST_UNKNOWN", fmt.Sprintf("Unknown tag: %s", reference), http.StatusNotFound)
114 return
115 }
116 }
117 manifest, ok := repo.manifests[digest]
118 s.mu.Unlock()
119 if !ok {
120 serveError(w, "MANIFEST_UNKNOWN", fmt.Sprintf("Unknown manifest: %s", digest), http.StatusNotFound)
121 return
122 }
123
124 w.Header().Set("Docker-Content-Digest", digest)
125 w.Header().Set("Etag", fmt.Sprintf(`"%s"`, digest))
126 w.Header().Set("Content-Type", manifest.contentType)
127 w.Header().Set("X-Content-Type-Options", "nosniff")
128 http.ServeContent(w, req, "", time.Time{}, bytes.NewReader(manifest.content))
129 } else if matches := blobsEp.FindStringSubmatch(req.URL.Path); len(matches) > 0 {
130 repository := matches[1]
131 digest := matches[2]
132 s.mu.Lock()
133 repo := s.repositories[repository]
134 if repo == nil {
135 s.mu.Unlock()
136 serveError(w, "NAME_UNKNOWN", fmt.Sprintf("Unknown repository: %s", repository), http.StatusNotFound)
137 return
138 }
139 blob, ok := repo.blobs[digest]
140 s.mu.Unlock()
141 if !ok {
142 serveError(w, "BLOB_UNKNOWN", fmt.Sprintf("Unknown blob: %s", digest), http.StatusNotFound)
143 return
144 }
145
146 content, err := blob.Open()
147 if err != nil {
148 http.Error(w, "Failed to open blob", http.StatusInternalServerError)
149 return
150 }
151 defer content.Close()
152 w.Header().Set("Docker-Content-Digest", digest)
153 w.Header().Set("Etag", fmt.Sprintf(`"%s"`, digest))
154 w.Header().Set("Content-Type", "application/octet-stream")
155 if contentSeeker, ok := content.(io.ReadSeeker); ok {
156 http.ServeContent(w, req, "", time.Time{}, contentSeeker)
157 } else {
158 // Range requests are not supported.
159 w.Header().Set("Content-Length", strconv.FormatInt(blob.Size(), 10))
160 w.WriteHeader(http.StatusOK)
161 if req.Method != "HEAD" {
162 io.CopyN(w, content, blob.Size())
163 }
164 }
165 } else {
166 w.WriteHeader(http.StatusNotFound)
167 }
168}
169
170func serveError(w http.ResponseWriter, code string, message string, statusCode int) {
171 w.Header().Set("Content-Type", "application/json; charset=utf-8")
172 w.Header().Set("X-Content-Type-Options", "nosniff")
173 w.WriteHeader(statusCode)
174 content, err := json.Marshal(&ErrorBody{Errors: []ErrorInfo{{
175 Code: code,
176 Message: message,
177 }}})
178 if err == nil {
179 w.Write(content)
180 }
181}