registry.go
⎇
Raw
1package web
2
3import (
4 "context"
5 "crypto/rand"
6 "crypto/sha256"
7 "encoding/hex"
8 "encoding/json"
9 "errors"
10 "io"
11 "net/http"
12 "os"
13 "path/filepath"
14 "regexp"
15 "strconv"
16 "strings"
17 "time"
18
19 "github.com/go-chi/chi/v5"
20
21 "hearthforge/internal/db"
22 "hearthforge/internal/ratelimit"
23 "hearthforge/internal/util"
24)
25
26// The registry implements the OCI Distribution Spec under /v2/. An image
27// name is "<repo>" or "<repo>/<path>"; the first segment must be an existing
28// repository. Blob and manifest bodies are content-addressed files under
29// DATA_DIR/registry, the per-image index lives in SQLite.
30//
31// Admins push and delete. Pull access follows REGISTRY_PULL, except that
32// images of private repositories are always admin-only.
33
34const (
35 // maxManifestBytes bounds a manifest body. Real ones are a few KiB.
36 maxManifestBytes = 4 << 20
37 registryRealm = `Basic realm="Hearthforge registry"`
38)
39
40var (
41 digestRe = regexp.MustCompile(`^sha256:[a-f0-9]{64}$`)
42 uploadIDRe = regexp.MustCompile(`^[a-f0-9]{32}$`)
43)
44
45// registryAuthLimiter counts argon2 checks per IP. Clients send Basic auth
46// on every request, so verified credentials are cached and skip it.
47var registryAuthLimiter = ratelimit.New(10, time.Minute)
48
49func (s *Server) registryRoutes(r chi.Router) {
50 r.HandleFunc("/v2", s.registry)
51 r.HandleFunc("/v2/", s.registry)
52 r.HandleFunc("/v2/*", s.registry)
53}
54
55// registryError writes the spec's JSON error envelope.
56func registryError(w http.ResponseWriter, status int, code, msg string) {
57 w.Header().Set("Content-Type", "application/json")
58 w.WriteHeader(status)
59 _ = json.NewEncoder(w).Encode(map[string]any{
60 "errors": []map[string]string{{"code": code, "message": msg}},
61 })
62}
63
64// registryUser resolves Basic auth. ok is false when the header is missing
65// or wrong. Each argon2 check is reserved in the limiter before it runs, so
66// parallel guesses count too.
67func (s *Server) registryUser(r *http.Request) (username string, isAdmin, ok bool) {
68 username, password, found := r.BasicAuth()
69 if !found || len(password) > s.Cfg.MaxPasswordBytes {
70 return "", false, false
71 }
72 hash, exists, err := s.DB.ActivePasswordHash(r.Context(), username)
73 if err != nil {
74 return "", false, false
75 }
76 key := sha256.Sum256([]byte(username + "\x00" + password))
77 if cached, ok := registryAuth.Get(key); exists && ok && cached == hash {
78 return username, username == db.AdminUsername, true
79 }
80 if !s.allowed(r, registryAuthLimiter, true) {
81 return "", false, false
82 }
83 if !exists {
84 return "", false, false
85 }
86 if valid, err := db.VerifyPassword(hash, password); err != nil || !valid {
87 return "", false, false
88 }
89 registryAuth.Set(key, hash)
90 return username, username == db.AdminUsername, true
91}
92
93// registryAuth remembers verified credentials. The value is the hash it
94// matched, so a password change invalidates the entry.
95var registryAuth = util.NewCache[[32]byte, string](1000, 5*time.Minute)
96
97// challenge answers 401 with the Basic scheme so clients retry with
98// credentials.
99func challenge(w http.ResponseWriter) {
100 w.Header().Set("WWW-Authenticate", registryRealm)
101 registryError(w, http.StatusUnauthorized, "UNAUTHORIZED", "authentication required")
102}
103
104// registry dispatches one /v2/ request. Names may contain slashes, so the
105// path is split on the fixed keywords instead of chi params.
106func (s *Server) registry(w http.ResponseWriter, r *http.Request) {
107 w.Header().Set("Docker-Distribution-API-Version", "registry/2.0")
108 _, isAdmin, authed := s.registryUser(r)
109 write := r.Method != http.MethodGet && r.Method != http.MethodHead
110
111 rest := strings.Trim(strings.TrimPrefix(r.URL.Path, "/v2"), "/")
112 if rest == "" {
113 // The version check doubles as the auth probe for clients.
114 if write || !s.Cfg.CanPullImages(false, authed, isAdmin) {
115 challenge(w)
116 return
117 }
118 w.Header().Set("Content-Type", "application/json")
119 _, _ = w.Write([]byte("{}"))
120 return
121 }
122
123 var name, kind, ref string
124 switch {
125 case strings.Contains(rest, "/blobs/uploads/") || strings.HasSuffix(rest, "/blobs/uploads"):
126 i := strings.LastIndex(rest, "/blobs/uploads")
127 name, kind = rest[:i], "upload"
128 ref = strings.TrimPrefix(rest[i+len("/blobs/uploads"):], "/")
129 case strings.Contains(rest, "/blobs/"):
130 i := strings.LastIndex(rest, "/blobs/")
131 name, kind, ref = rest[:i], "blob", rest[i+len("/blobs/"):]
132 case strings.Contains(rest, "/manifests/"):
133 i := strings.LastIndex(rest, "/manifests/")
134 name, kind, ref = rest[:i], "manifest", rest[i+len("/manifests/"):]
135 case strings.HasSuffix(rest, "/tags/list"):
136 name, kind = strings.TrimSuffix(rest, "/tags/list"), "tags"
137 default:
138 registryError(w, http.StatusNotFound, "UNSUPPORTED", "unknown endpoint")
139 return
140 }
141
142 repo, image, ok := s.registryName(r, name)
143 // A private repo must look exactly like an unknown one to non-admins,
144 // or its name leaks through the status code.
145 if ok && repo.IsPrivate && !isAdmin {
146 ok = false
147 }
148 if !ok {
149 // Do not reveal whether the repo exists before auth.
150 if !authed && (write || !s.Cfg.CanPullImages(false, false, false)) {
151 challenge(w)
152 return
153 }
154 registryError(w, http.StatusNotFound, "NAME_UNKNOWN", "repository name not known to registry")
155 return
156 }
157 if write && !isAdmin {
158 if !authed {
159 challenge(w)
160 return
161 }
162 registryError(w, http.StatusForbidden, "DENIED", "only the admin may push")
163 return
164 }
165 if !write && !s.Cfg.CanPullImages(repo.IsPrivate, authed, isAdmin) {
166 if !authed {
167 challenge(w)
168 return
169 }
170 registryError(w, http.StatusForbidden, "DENIED", "pull access denied")
171 return
172 }
173
174 switch kind {
175 case "upload":
176 s.registryUpload(w, r, repo, image, ref)
177 case "blob":
178 s.registryBlob(w, r, repo, image, ref)
179 case "manifest":
180 s.registryManifest(w, r, repo, image, ref)
181 case "tags":
182 s.registryTags(w, r, repo, image)
183 }
184}
185
186// registryName maps "<repo>[/<path>]" to the repository row and image path.
187// It reports false for an unknown repo or a path the spec would reject.
188func (s *Server) registryName(r *http.Request, name string) (*db.Repo, string, bool) {
189 repoName, image, _ := strings.Cut(name, "/")
190 if !util.ValidImagePath(image) {
191 return nil, "", false
192 }
193 repo, err := s.DB.RepoByNameFold(r.Context(), repoName)
194 if err != nil || repo == nil {
195 return nil, "", false
196 }
197 return repo, image, true
198}
199
200// imageBase is the URL prefix of an image. Image names are lowercase on the
201// wire, so the repo name is lowered even when the repository is not.
202func imageBase(repo *db.Repo, image string) string {
203 return "/v2/" + strings.TrimSuffix(strings.ToLower(repo.Name)+"/"+image, "/")
204}
205
206// --- storage paths ---
207
208func (s *Server) blobPath(digest string) string {
209 return filepath.Join(s.Cfg.RegistryDir(), "blobs", strings.Replace(digest, ":", "/", 1))
210}
211
212func (s *Server) uploadPath(id string) string {
213 return filepath.Join(s.Cfg.RegistryDir(), "uploads", id)
214}
215
216// --- blob uploads ---
217
218func (s *Server) registryUpload(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, id string) {
219 base := imageBase(repo, image)
220 switch {
221 case r.Method == http.MethodPost && id == "":
222 // A cross-repo mount request falls through to a normal upload, which
223 // the spec allows. The client then uploads the blob.
224 if digest := r.URL.Query().Get("digest"); digest != "" && r.URL.Query().Get("mount") == "" {
225 // Monolithic upload in one request.
226 tmp, err := s.newUpload()
227 if err != nil {
228 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
229 return
230 }
231 if _, err := appendUpload(s.uploadPath(tmp), r.Body); err != nil {
232 _ = os.Remove(s.uploadPath(tmp))
233 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
234 return
235 }
236 s.finishUpload(w, r, repo, image, tmp, digest, base)
237 return
238 }
239 id, err := s.newUpload()
240 if err != nil {
241 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
242 return
243 }
244 w.Header().Set("Location", base+"/blobs/uploads/"+id)
245 w.Header().Set("Docker-Upload-UUID", id)
246 w.Header().Set("Range", "0-0")
247 w.WriteHeader(http.StatusAccepted)
248
249 case id == "" || !uploadIDRe.MatchString(id):
250 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
251
252 case r.Method == http.MethodGet:
253 st, err := os.Stat(s.uploadPath(id))
254 if err != nil {
255 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
256 return
257 }
258 w.Header().Set("Location", base+"/blobs/uploads/"+id)
259 w.Header().Set("Docker-Upload-UUID", id)
260 w.Header().Set("Range", rangeHeader(st.Size()))
261 w.WriteHeader(http.StatusNoContent)
262
263 case r.Method == http.MethodPatch:
264 path := s.uploadPath(id)
265 st, err := os.Stat(path)
266 if err != nil {
267 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
268 return
269 }
270 // Chunks must arrive in order. A stated start that is not the
271 // current end means the client and server disagree on the state.
272 if cr := r.Header.Get("Content-Range"); cr != "" {
273 start, _, _ := strings.Cut(cr, "-")
274 if n, err := strconv.ParseInt(start, 10, 64); err != nil || n != st.Size() {
275 w.Header().Set("Location", base+"/blobs/uploads/"+id)
276 w.Header().Set("Range", rangeHeader(st.Size()))
277 registryError(w, http.StatusRequestedRangeNotSatisfiable, "BLOB_UPLOAD_INVALID", "chunk out of order")
278 return
279 }
280 }
281 n, err := appendUpload(path, r.Body)
282 if err != nil {
283 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
284 return
285 }
286 w.Header().Set("Location", base+"/blobs/uploads/"+id)
287 w.Header().Set("Docker-Upload-UUID", id)
288 w.Header().Set("Range", rangeHeader(st.Size()+n))
289 w.WriteHeader(http.StatusAccepted)
290
291 case r.Method == http.MethodPut:
292 path := s.uploadPath(id)
293 if _, err := os.Stat(path); err != nil {
294 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
295 return
296 }
297 if _, err := appendUpload(path, r.Body); err != nil {
298 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
299 return
300 }
301 s.finishUpload(w, r, repo, image, id, r.URL.Query().Get("digest"), base)
302
303 case r.Method == http.MethodDelete:
304 if err := os.Remove(s.uploadPath(id)); err != nil {
305 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
306 return
307 }
308 w.WriteHeader(http.StatusNoContent)
309
310 default:
311 registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
312 }
313}
314
315func rangeHeader(size int64) string {
316 if size == 0 {
317 return "0-0"
318 }
319 return "0-" + strconv.FormatInt(size-1, 10)
320}
321
322// staleUploadAge is how long a partial upload may sit before it is removed.
323const staleUploadAge = 24 * time.Hour
324
325// sweepUploads removes upload files nobody finished. A client that
326// disconnects mid-push never sends the final request, so nothing else
327// would ever delete them.
328func (s *Server) sweepUploads() {
329 entries, err := os.ReadDir(filepath.Dir(s.uploadPath("x")))
330 if err != nil {
331 return
332 }
333 cutoff := time.Now().Add(-staleUploadAge)
334 for _, e := range entries {
335 if info, err := e.Info(); err == nil && info.ModTime().Before(cutoff) {
336 _ = os.Remove(s.uploadPath(e.Name()))
337 }
338 }
339}
340
341// newUpload creates an empty upload file and returns its id.
342func (s *Server) newUpload() (string, error) {
343 s.sweepUploads()
344 var b [16]byte
345 if _, err := rand.Read(b[:]); err != nil {
346 return "", err
347 }
348 id := hex.EncodeToString(b[:])
349 if err := os.MkdirAll(filepath.Dir(s.uploadPath(id)), 0o755); err != nil {
350 return "", err
351 }
352 f, err := os.OpenFile(s.uploadPath(id), os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o600)
353 if err != nil {
354 return "", err
355 }
356 return id, f.Close()
357}
358
359// appendUpload appends the body and returns how many bytes it added.
360func appendUpload(path string, body io.Reader) (int64, error) {
361 f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
362 if err != nil {
363 return 0, err
364 }
365 n, err := io.Copy(f, body)
366 if err != nil {
367 f.Close()
368 return n, err
369 }
370 return n, f.Close()
371}
372
373// finishUpload verifies the digest, moves the file into the blob store, and
374// links it to the image.
375func (s *Server) finishUpload(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, id, digest, base string) {
376 // Claim the file under a private name first. A late PATCH on the upload
377 // id then finds nothing, so the bytes hashed are the bytes stored.
378 path := s.uploadPath(id) + ".final"
379 if err := os.Rename(s.uploadPath(id), path); err != nil {
380 registryError(w, http.StatusNotFound, "BLOB_UPLOAD_UNKNOWN", "upload not found")
381 return
382 }
383 if !digestRe.MatchString(digest) {
384 _ = os.Remove(path)
385 registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest must be sha256:<hex>")
386 return
387 }
388 if err := s.commitBlob(r.Context(), repo.ID, image, path, digest); err != nil {
389 if errors.Is(err, errDigestMismatch) {
390 registryError(w, http.StatusBadRequest, "DIGEST_INVALID", err.Error())
391 return
392 }
393 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
394 return
395 }
396 w.Header().Set("Location", base+"/blobs/"+digest)
397 w.Header().Set("Docker-Content-Digest", digest)
398 w.WriteHeader(http.StatusCreated)
399}
400
401var errDigestMismatch = errors.New("digest does not match uploaded content")
402
403// commitBlob checks the file at path against digest, moves it into the blob
404// store, and links it to the image. The file is gone afterwards either way.
405func (s *Server) commitBlob(ctx context.Context, repoID int64, image, path, digest string) error {
406 size, actual, err := fileDigest(path)
407 if err == nil && actual != digest {
408 err = errDigestMismatch
409 }
410 if err != nil {
411 _ = os.Remove(path)
412 return err
413 }
414 s.registryMu.Lock()
415 defer s.registryMu.Unlock()
416 if err := s.storeBlob(path, digest); err != nil {
417 _ = os.Remove(path)
418 return err
419 }
420 return s.DB.LinkBlob(ctx, repoID, image, digest, size)
421}
422
423// storeBlob moves a verified file into place. An existing blob with the
424// same digest has identical content, so the upload is simply dropped.
425func (s *Server) storeBlob(src, digest string) error {
426 dst := s.blobPath(digest)
427 if _, err := os.Stat(dst); err == nil {
428 return os.Remove(src)
429 }
430 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
431 return err
432 }
433 return os.Rename(src, dst)
434}
435
436func fileDigest(path string) (int64, string, error) {
437 f, err := os.Open(path)
438 if err != nil {
439 return 0, "", err
440 }
441 defer f.Close()
442 h := sha256.New()
443 n, err := io.Copy(h, f)
444 if err != nil {
445 return 0, "", err
446 }
447 return n, "sha256:" + hex.EncodeToString(h.Sum(nil)), nil
448}
449
450// --- blobs ---
451
452func (s *Server) registryBlob(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, digest string) {
453 if !digestRe.MatchString(digest) {
454 registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "digest must be sha256:<hex>")
455 return
456 }
457 linked, err := s.DB.BlobLinked(r.Context(), repo.ID, image, digest)
458 if err != nil {
459 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
460 return
461 }
462 if !linked {
463 registryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob unknown to registry")
464 return
465 }
466 switch r.Method {
467 case http.MethodGet, http.MethodHead:
468 s.serveDigest(w, r, digest, "application/octet-stream")
469 case http.MethodDelete:
470 s.registryMu.Lock()
471 err = s.DB.UnlinkBlob(r.Context(), repo.ID, image, digest)
472 if err == nil {
473 s.removeUnreferenced(r.Context(), digest)
474 }
475 s.registryMu.Unlock()
476 if err != nil {
477 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
478 return
479 }
480 w.WriteHeader(http.StatusAccepted)
481 default:
482 registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
483 }
484}
485
486// removeUnreferenced deletes the file behind digest once no image uses it.
487// The caller holds registryMu. The lookup failing keeps the file; a stray
488// file is cheaper than a broken pull.
489func (s *Server) removeUnreferenced(ctx context.Context, digest string) {
490 used, err := s.DB.DigestReferenced(ctx, digest)
491 if err == nil && !used {
492 _ = os.Remove(s.blobPath(digest))
493 }
494}
495
496// serveDigest streams a content-addressed file. ServeContent handles HEAD
497// and Range requests.
498func (s *Server) serveDigest(w http.ResponseWriter, r *http.Request, digest, contentType string) {
499 f, err := os.Open(s.blobPath(digest))
500 if err != nil {
501 registryError(w, http.StatusNotFound, "BLOB_UNKNOWN", "blob file missing")
502 return
503 }
504 defer f.Close()
505 st, err := f.Stat()
506 if err != nil {
507 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
508 return
509 }
510 w.Header().Set("Content-Type", contentType)
511 w.Header().Set("Docker-Content-Digest", digest)
512 // Content-Disposition keeps a browser from rendering a layer or
513 // manifest inline. The raw endpoint's sandbox CSP does not apply here.
514 w.Header().Set("Content-Disposition", "attachment")
515 http.ServeContent(w, r, "", st.ModTime(), f)
516}
517
518// --- manifests ---
519
520// manifestRefs is the part of a manifest or index the registry checks.
521type manifestRefs struct {
522 MediaType string `json:"mediaType"`
523 Config *struct {
524 Digest string `json:"digest"`
525 } `json:"config"`
526 Layers []struct {
527 Digest string `json:"digest"`
528 } `json:"layers"`
529 Manifests []struct {
530 Digest string `json:"digest"`
531 } `json:"manifests"`
532}
533
534// blobs lists the config and layer digests.
535func (m manifestRefs) blobs() []string {
536 var out []string
537 if m.Config != nil {
538 out = append(out, m.Config.Digest)
539 }
540 for _, l := range m.Layers {
541 out = append(out, l.Digest)
542 }
543 return out
544}
545
546// children lists the manifest digests of an index.
547func (m manifestRefs) children() []string {
548 var out []string
549 for _, c := range m.Manifests {
550 out = append(out, c.Digest)
551 }
552 return out
553}
554
555func (s *Server) registryManifest(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, ref string) {
556 isDigest := digestRe.MatchString(ref)
557 if !isDigest && !util.ValidImageTag(ref) {
558 registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "invalid reference")
559 return
560 }
561 switch r.Method {
562 case http.MethodPut:
563 s.putManifest(w, r, repo, image, ref, isDigest)
564 return
565 case http.MethodDelete:
566 s.deleteManifest(w, r, repo, image, ref, isDigest)
567 return
568 case http.MethodGet, http.MethodHead:
569 default:
570 registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
571 return
572 }
573
574 var m *db.Manifest
575 var err error
576 if isDigest {
577 m, err = s.DB.ManifestByDigest(r.Context(), repo.ID, image, ref)
578 } else {
579 m, err = s.DB.ManifestByTag(r.Context(), repo.ID, image, ref)
580 }
581 if err != nil {
582 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
583 return
584 }
585 if m == nil {
586 registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest unknown")
587 return
588 }
589 s.serveDigest(w, r, m.Digest, m.MediaType)
590}
591
592// deleteManifest removes a tag, or a manifest with its tags, and then the
593// manifests and blobs nothing uses any more.
594func (s *Server) deleteManifest(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, ref string, isDigest bool) {
595 ctx := r.Context()
596 s.registryMu.Lock()
597 defer s.registryMu.Unlock()
598 if !isDigest {
599 digest, err := s.DB.DeleteTag(ctx, repo.ID, image, ref)
600 if err != nil {
601 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
602 return
603 }
604 if digest == "" {
605 registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "tag unknown")
606 return
607 }
608 s.dropUnused(ctx, repo.ID, image, []string{digest}, nil)
609 w.WriteHeader(http.StatusAccepted)
610 return
611 }
612 used, err := s.imageRefs(ctx, repo.ID, image)
613 if err != nil {
614 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
615 return
616 }
617 if used[ref] {
618 registryError(w, http.StatusConflict, "DENIED", "an index still uses this manifest")
619 return
620 }
621 // A broken file only leaves its blobs linked until the startup sweep.
622 refs, _ := s.readManifestRefs(ref)
623 found, err := s.DB.DeleteManifest(ctx, repo.ID, image, ref)
624 if err != nil {
625 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
626 return
627 }
628 if !found {
629 registryError(w, http.StatusNotFound, "MANIFEST_UNKNOWN", "manifest unknown")
630 return
631 }
632 s.removeUnreferenced(ctx, ref)
633 s.dropUnused(ctx, repo.ID, image, refs.children(), refs.blobs())
634 w.WriteHeader(http.StatusAccepted)
635}
636
637// putManifest stores a manifest after checking that everything it points
638// at is already in this image. That is what makes a pull reliable.
639func (s *Server) putManifest(w http.ResponseWriter, r *http.Request, repo *db.Repo, image, ref string, isDigest bool) {
640 body, err := io.ReadAll(io.LimitReader(r.Body, maxManifestBytes+1))
641 if err != nil {
642 registryError(w, http.StatusBadRequest, "MANIFEST_INVALID", "could not read body")
643 return
644 }
645 if len(body) > maxManifestBytes {
646 registryError(w, http.StatusRequestEntityTooLarge, "MANIFEST_INVALID", "manifest too large")
647 return
648 }
649 if isDigest && ref != bodyDigest(body) {
650 registryError(w, http.StatusBadRequest, "DIGEST_INVALID", "reference digest does not match body")
651 return
652 }
653 mediaType, _, _ := strings.Cut(r.Header.Get("Content-Type"), ";")
654 tag := ""
655 if !isDigest {
656 tag = ref
657 }
658 digest, err := s.storeManifest(r.Context(), repo, image, body, strings.TrimSpace(mediaType), tag)
659 if err != nil {
660 var refused *manifestRefused
661 if errors.As(err, &refused) {
662 registryError(w, http.StatusBadRequest, refused.code, refused.msg)
663 return
664 }
665 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
666 return
667 }
668 w.Header().Set("Location", imageBase(repo, image)+"/manifests/"+digest)
669 w.Header().Set("Docker-Content-Digest", digest)
670 w.WriteHeader(http.StatusCreated)
671}
672
673func bodyDigest(body []byte) string {
674 sum := sha256.Sum256(body)
675 return "sha256:" + hex.EncodeToString(sum[:])
676}
677
678// manifestRefused is a manifest the registry rejects, with its spec code.
679type manifestRefused struct{ code, msg string }
680
681func (e *manifestRefused) Error() string { return e.msg }
682
683// storeManifest stores a manifest after checking that everything it points
684// at is already in this image. That is what makes a pull reliable. An empty
685// mediaType falls back to the one in the body. An empty tag stores the
686// manifest by digest only.
687func (s *Server) storeManifest(ctx context.Context, repo *db.Repo, image string, body []byte, mediaType, tag string) (string, error) {
688 var refs manifestRefs
689 if err := json.Unmarshal(body, &refs); err != nil {
690 return "", &manifestRefused{"MANIFEST_INVALID", "manifest is not valid JSON"}
691 }
692 if mediaType == "" {
693 mediaType = refs.MediaType
694 }
695 if mediaType == "" {
696 return "", &manifestRefused{"MANIFEST_INVALID", "manifest has no media type"}
697 }
698
699 // Every layer and config blob must be linked, every child manifest
700 // stored. Otherwise a client could pull a manifest whose parts 404.
701 // The lock covers the checks, so a cleanup cannot unlink a part
702 // between check and store.
703 s.registryMu.Lock()
704 defer s.registryMu.Unlock()
705 for _, d := range refs.blobs() {
706 if !digestRe.MatchString(d) {
707 return "", &manifestRefused{"MANIFEST_INVALID", "unsupported digest " + d}
708 }
709 linked, err := s.DB.BlobLinked(ctx, repo.ID, image, d)
710 if err != nil {
711 return "", err
712 }
713 if !linked {
714 return "", &manifestRefused{"MANIFEST_BLOB_UNKNOWN", "blob " + d + " not uploaded"}
715 }
716 }
717 for _, c := range refs.Manifests {
718 if !digestRe.MatchString(c.Digest) {
719 return "", &manifestRefused{"MANIFEST_INVALID", "unsupported digest " + c.Digest}
720 }
721 child, err := s.DB.ManifestByDigest(ctx, repo.ID, image, c.Digest)
722 if err != nil {
723 return "", err
724 }
725 if child == nil {
726 return "", &manifestRefused{"MANIFEST_BLOB_UNKNOWN", "manifest " + c.Digest + " not uploaded"}
727 }
728 }
729
730 digest := bodyDigest(body)
731 m := db.Manifest{Digest: digest, MediaType: mediaType}
732 if err := s.writeBlob(digest, body); err != nil {
733 return "", err
734 }
735 prev, err := s.DB.PutManifest(ctx, repo.ID, image, m, tag, db.NowISO())
736 if err != nil || prev == "" || prev == digest {
737 return digest, err
738 }
739 // A moved tag can leave the old manifest and its layers unused.
740 s.dropUnused(ctx, repo.ID, image, []string{prev}, nil)
741 return digest, nil
742}
743
744// writeBlob stores small content (a manifest) by digest.
745func (s *Server) writeBlob(digest string, body []byte) error {
746 dst := s.blobPath(digest)
747 if _, err := os.Stat(dst); err == nil {
748 return nil
749 }
750 if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
751 return err
752 }
753 tmp, err := os.CreateTemp(filepath.Dir(dst), ".manifest-*")
754 if err != nil {
755 return err
756 }
757 if _, err := tmp.Write(body); err != nil {
758 tmp.Close()
759 _ = os.Remove(tmp.Name())
760 return err
761 }
762 if err := tmp.Close(); err != nil {
763 _ = os.Remove(tmp.Name())
764 return err
765 }
766 return os.Rename(tmp.Name(), dst)
767}
768
769// --- tags ---
770
771func (s *Server) registryTags(w http.ResponseWriter, r *http.Request, repo *db.Repo, image string) {
772 if r.Method != http.MethodGet && r.Method != http.MethodHead {
773 registryError(w, http.StatusMethodNotAllowed, "UNSUPPORTED", "method not allowed")
774 return
775 }
776 tags, err := s.DB.ListTags(r.Context(), repo.ID, image)
777 if err != nil {
778 registryError(w, http.StatusInternalServerError, "UNKNOWN", err.Error())
779 return
780 }
781 // Pagination: `last` is exclusive, `n` caps the page.
782 if last := r.URL.Query().Get("last"); last != "" {
783 for len(tags) > 0 && tags[0] <= last {
784 tags = tags[1:]
785 }
786 }
787 if n, err := strconv.Atoi(r.URL.Query().Get("n")); err == nil && n >= 0 && n < len(tags) {
788 tags = tags[:n]
789 }
790 if tags == nil {
791 tags = []string{}
792 }
793 w.Header().Set("Content-Type", "application/json")
794 _ = json.NewEncoder(w).Encode(map[string]any{
795 "name": strings.TrimPrefix(imageBase(repo, image), "/v2/"),
796 "tags": tags,
797 })
798}
799