Repository: google/go-containerregistry
Pull request #1917 (2024-04-09 19:03:55)

registry: Implement Range requests for blobs
This makes crane registry usable with registry explorer.


Patch:
diff --git a/pkg/registry/blobs.go b/pkg/registry/blobs.go
index 8386ffdf..c83e5479 100644
--- a/pkg/registry/blobs.go
+++ b/pkg/registry/blobs.go
@@ -93,6 +93,14 @@ type redirectError struct {
 	Code int
 }
 
+type bytesCloser struct {
+	*bytes.Reader
+}
+
+func (r *bytesCloser) Close() error {
+	return nil
+}
+
 func (e redirectError) Error() string { return fmt.Sprintf("redirecting (%d): %s", e.Code, e.Location) }
 
 // errNotFound represents an error locating the blob.
@@ -115,6 +123,7 @@ func (m *memHandler) Stat(_ context.Context, _ string, h v1.Hash) (int64, error)
 	}
 	return int64(len(b)), nil
 }
+
 func (m *memHandler) Get(_ context.Context, _ string, h v1.Hash) (io.ReadCloser, error) {
 	m.lock.Lock()
 	defer m.lock.Unlock()
@@ -123,8 +132,9 @@ func (m *memHandler) Get(_ context.Context, _ string, h v1.Hash) (io.ReadCloser,
 	if !found {
 		return nil, errNotFound
 	}
-	return io.NopCloser(bytes.NewReader(b)), nil
+	return &bytesCloser{bytes.NewReader(b)}, nil
 }
+
 func (m *memHandler) Put(_ context.Context, _ string, h v1.Hash, rc io.ReadCloser) error {
 	m.lock.Lock()
 	defer m.lock.Unlock()
@@ -137,6 +147,7 @@ func (m *memHandler) Put(_ context.Context, _ string, h v1.Hash, rc io.ReadClose
 	m.m[h.String()] = all
 	return nil
 }
+
 func (m *memHandler) Delete(_ context.Context, _ string, h v1.Hash) error {
 	m.lock.Lock()
 	defer m.lock.Unlock()
@@ -177,6 +188,7 @@ func (b *blobs) handle(resp http.ResponseWriter, req *http.Request) *regError {
 	service := elem[len(elem)-2]
 	digest := req.URL.Query().Get("digest")
 	contentRange := req.Header.Get("Content-Range")
+	rangeHeader := req.Header.Get("Range")
 
 	repo := req.URL.Host + path.Join(elem[1:len(elem)-2]...)
 
@@ -265,8 +277,10 @@ func (b *blobs) handle(resp http.ResponseWriter, req *http.Request) *regError {
 
 				return regErrInternal(err)
 			}
+
 			defer rc.Close()
 			r = rc
+
 		} else {
 			tmp, err := b.blobHandler.Get(req.Context(), repo, h)
 			if errors.Is(err, errNotFound) {
@@ -287,9 +301,48 @@ func (b *blobs) handle(resp http.ResponseWriter, req *http.Request) *regError {
 			r = &buf
 		}
 
-		resp.Header().Set("Content-Length", fmt.Sprint(size))
-		resp.Header().Set("Docker-Content-Digest", h.String())
-		resp.WriteHeader(http.StatusOK)
+		if rangeHeader != "" {
+			start, end := int64(0), int64(0)
+			if _, err := fmt.Sscanf(rangeHeader, "bytes=%d-%d", &start, &end); err != nil {
+				return &regError{
+					Status:  http.StatusRequestedRangeNotSatisfiable,
+					Code:    "BLOB_UNKNOWN",
+					Message: "We don't understand your Range",
+				}
+			}
+
+			n := (end + 1) - start
+			if ra, ok := r.(io.ReaderAt); ok {
+				if end+1 > size {
+					return &regError{
+						Status:  http.StatusRequestedRangeNotSatisfiable,
+						Code:    "BLOB_UNKNOWN",
+						Message: fmt.Sprintf("range end %d > %d size", end+1, size),
+					}
+				}
+				r = io.NewSectionReader(ra, start, n)
+			} else {
+				if _, err := io.CopyN(io.Discard, r, start); err != nil {
+					return &regError{
+						Status:  http.StatusRequestedRangeNotSatisfiable,
+						Code:    "BLOB_UNKNOWN",
+						Message: fmt.Sprintf("Failed to discard %d bytes", start),
+					}
+				}
+
+				r = io.LimitReader(r, n)
+			}
+
+			resp.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, size))
+			resp.Header().Set("Content-Length", fmt.Sprint(n))
+			resp.Header().Set("Docker-Content-Digest", h.String())
+			resp.WriteHeader(http.StatusPartialContent)
+		} else {
+			resp.Header().Set("Content-Length", fmt.Sprint(size))
+			resp.Header().Set("Docker-Content-Digest", h.String())
+			resp.WriteHeader(http.StatusOK)
+		}
+
 		io.Copy(resp, r)
 		return nil
 


Test patch:
diff --git a/pkg/registry/registry_test.go b/pkg/registry/registry_test.go
index a9c257b5..b14063bb 100644
--- a/pkg/registry/registry_test.go
+++ b/pkg/registry/registry_test.go
@@ -137,6 +137,42 @@ func TestCalls(t *testing.T) {
 			Header:      map[string]string{"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae"},
 			Want:        "foo",
 		},
+		{
+			Description: "GET blob range",
+			Digests:     map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
+			Method:      "GET",
+			URL:         "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
+			Code:        http.StatusPartialContent,
+			RequestHeader: map[string]string{
+				"Range": "bytes=1-2",
+			},
+			Header: map[string]string{
+				"Docker-Content-Digest": "sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
+				"Content-Length":        "2",
+				"Content-Range":         "bytes 1-2/3",
+			},
+			Want: "oo",
+		},
+		{
+			Description: "GET invalid range header",
+			Digests:     map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
+			Method:      "GET",
+			URL:         "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
+			RequestHeader: map[string]string{
+				"Range": "nibbles=123-456",
+			},
+			Code: http.StatusRequestedRangeNotSatisfiable,
+		},
+		{
+			Description: "GET bad blob range",
+			Digests:     map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},
+			Method:      "GET",
+			URL:         "/v2/foo/blobs/sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae",
+			RequestHeader: map[string]string{
+				"Range": "bytes=1-3",
+			},
+			Code: http.StatusRequestedRangeNotSatisfiable,
+		},
 		{
 			Description: "HEAD blob",
 			Digests:     map[string]string{"sha256:2c26b46b68ffc68ff99b453c1d30413413422d706483bfa0f98a5e886266e7ae": "foo"},