detached 8488ad1

fix: limit cumulative registry blob uploads

commit
8488ad1a1f2e827a0f5e7922478199572e94bdd9
author
brice · · 2026-04-25 08:43 UTC
parent
26d49b5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +335 -51
M internal/adapters/in/http/registry/handler.go +16 -1
@@ -26,6 +26,8 @@ const (
2626 // Kept at 95MB to stay within Cloudflare's 100MB per-request limit.
2727 // Users who need larger chunks can configure it explicitly via max_blob_chunk_size.
2828 DefaultMaxBlobChunkSize = 95 * 1024 * 1024
29+ // DefaultMaxBlobSize is the default maximum cumulative size for a blob upload.
30+ DefaultMaxBlobSize = 1024 * 1024 * 1024
2931 )
3032
3133 // Handler implements the HTTP handler for Docker Registry API v2.
@@ -33,6 +35,7 @@ type Handler struct {
3335 registrySvc in.RegistryService
3436 log zerowrap.Logger
3537 maxBlobChunkSize int64
38+ maxBlobSize int64
3639 }
3740
3841 // NewHandler creates a new registry HTTP handler.
@@ -40,15 +43,21 @@ func NewHandler(
4043 registrySvc in.RegistryService,
4144 log zerowrap.Logger,
4245 maxBlobChunkSize int64,
46+ maxBlobSize ...int64,
4347 ) *Handler {
4448 if maxBlobChunkSize <= 0 {
4549 maxBlobChunkSize = DefaultMaxBlobChunkSize
4650 }
51+ blobSizeLimit := int64(DefaultMaxBlobSize)
52+ if len(maxBlobSize) > 0 && maxBlobSize[0] > 0 {
53+ blobSizeLimit = maxBlobSize[0]
54+ }
4755
4856 return &Handler{
4957 registrySvc: registrySvc,
5058 log: log,
5159 maxBlobChunkSize: maxBlobChunkSize,
60+ maxBlobSize: blobSizeLimit,
5261 }
5362 }
5463
@@ -413,7 +422,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
413422
414423 // Stream the body directly to storage — memory usage is bounded to a small
415424 // copy buffer (~32KB) regardless of chunk size.
416- length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body)
425+ length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body, r.ContentLength, h.maxBlobSize)
417426 if err != nil {
418427 var maxBytesErr *http.MaxBytesError
419428 if errors.As(err, &maxBytesErr) {
@@ -421,6 +430,12 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
421430 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
422431 return
423432 }
433+ if errors.Is(err, domain.ErrBlobSizeExceeded) {
434+ log.Warn().Int64("max_size", h.maxBlobSize).Msg("blob upload too large")
435+ _ = h.registrySvc.CancelUpload(ctx, uuid)
436+ h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob exceeds maximum size")
437+ return
438+ }
424439 log.Error().Err(err).Msg("failed to append blob chunk")
425440 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")
426441 return
@@ -26,6 +26,8 @@ const (
26 // Kept at 95MB to stay within Cloudflare's 100MB per-request limit.26 // Kept at 95MB to stay within Cloudflare's 100MB per-request limit.
27 // Users who need larger chunks can configure it explicitly via max_blob_chunk_size.27 // Users who need larger chunks can configure it explicitly via max_blob_chunk_size.
28 DefaultMaxBlobChunkSize = 95 * 1024 * 102428 DefaultMaxBlobChunkSize = 95 * 1024 * 1024
29+ // DefaultMaxBlobSize is the default maximum cumulative size for a blob upload.
30+ DefaultMaxBlobSize = 1024 * 1024 * 1024
29 )31 )
30 32
31 // Handler implements the HTTP handler for Docker Registry API v2.33 // Handler implements the HTTP handler for Docker Registry API v2.
@@ -33,6 +35,7 @@ type Handler struct {
33 registrySvc in.RegistryService35 registrySvc in.RegistryService
34 log zerowrap.Logger36 log zerowrap.Logger
35 maxBlobChunkSize int6437 maxBlobChunkSize int64
38+ maxBlobSize int64
36 }39 }
37 40
38 // NewHandler creates a new registry HTTP handler.41 // NewHandler creates a new registry HTTP handler.
@@ -40,15 +43,21 @@ func NewHandler(
40 registrySvc in.RegistryService,43 registrySvc in.RegistryService,
41 log zerowrap.Logger,44 log zerowrap.Logger,
42 maxBlobChunkSize int64,45 maxBlobChunkSize int64,
46+ maxBlobSize ...int64,
43 ) *Handler {47 ) *Handler {
44 if maxBlobChunkSize <= 0 {48 if maxBlobChunkSize <= 0 {
45 maxBlobChunkSize = DefaultMaxBlobChunkSize49 maxBlobChunkSize = DefaultMaxBlobChunkSize
46 }50 }
51+ blobSizeLimit := int64(DefaultMaxBlobSize)
52+ if len(maxBlobSize) > 0 && maxBlobSize[0] > 0 {
53+ blobSizeLimit = maxBlobSize[0]
54+ }
47 55
48 return &Handler{56 return &Handler{
49 registrySvc: registrySvc,57 registrySvc: registrySvc,
50 log: log,58 log: log,
51 maxBlobChunkSize: maxBlobChunkSize,59 maxBlobChunkSize: maxBlobChunkSize,
60+ maxBlobSize: blobSizeLimit,
52 }61 }
53 }62 }
54 63
@@ -413,7 +422,7 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
413 422
414 // Stream the body directly to storage — memory usage is bounded to a small423 // Stream the body directly to storage — memory usage is bounded to a small
415 // copy buffer (~32KB) regardless of chunk size.424 // copy buffer (~32KB) regardless of chunk size.
416- length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body)425+ length, err := h.registrySvc.AppendBlobChunk(ctx, name, uuid, r.Body, r.ContentLength, h.maxBlobSize)
417 if err != nil {426 if err != nil {
418 var maxBytesErr *http.MaxBytesError427 var maxBytesErr *http.MaxBytesError
419 if errors.As(err, &maxBytesErr) {428 if errors.As(err, &maxBytesErr) {
@@ -421,6 +430,12 @@ func (h *Handler) handleBlobUpload(w http.ResponseWriter, r *http.Request) {
421 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")430 h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob chunk exceeds maximum size")
422 return431 return
423 }432 }
433+ if errors.Is(err, domain.ErrBlobSizeExceeded) {
434+ log.Warn().Int64("max_size", h.maxBlobSize).Msg("blob upload too large")
435+ _ = h.registrySvc.CancelUpload(ctx, uuid)
436+ h.sendRegistryError(w, http.StatusRequestEntityTooLarge, "SIZE_INVALID", "blob exceeds maximum size")
437+ return
438+ }
424 log.Error().Err(err).Msg("failed to append blob chunk")439 log.Error().Err(err).Msg("failed to append blob chunk")
425 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")440 h.sendRegistryError(w, http.StatusInternalServerError, "BLOB_UPLOAD_UNKNOWN", "failed to append blob chunk")
426 return441 return
M internal/adapters/in/http/registry/handler_test.go +80 -6
@@ -293,7 +293,7 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) {
293293 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
294294
295295 chunkData := []byte("chunk content")
296- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)
296+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
297297
298298 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
299299 rec := httptest.NewRecorder()
@@ -310,7 +310,7 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
310310 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
311311
312312 chunkData := []byte("final chunk")
313- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)
313+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
314314 registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
315315
316316 req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
@@ -432,7 +432,7 @@ func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {
432432 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
433433
434434 chunkData := []byte("chunk content")
435- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)
435+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
436436
437437 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
438438 rec := httptest.NewRecorder()
@@ -453,8 +453,8 @@ func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing.
453453
454454 // The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk.
455455 // The mock must read the body to trigger MaxBytesError, which the handler detects.
456- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).
457- Run(func(_ context.Context, _ string, _ string, data io.Reader) {
456+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).
457+ Run(func(_ context.Context, _ string, _ string, data io.Reader, _ int64, _ int64) {
458458 // Drain the reader to trigger MaxBytesError
459459 _, _ = io.ReadAll(data)
460460 }).
@@ -473,7 +473,7 @@ func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
473473 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
474474
475475 chunkData := []byte("chunk content")
476- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)
476+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
477477
478478 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
479479 rec := httptest.NewRecorder()
@@ -484,3 +484,77 @@ func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
484484 // OCI spec: Range header should reflect total received bytes
485485 assert.Equal(t, "0-12", rec.Header().Get("Range"))
486486 }
487+
488+func TestDefaultMaxBlobSize_Is1GB(t *testing.T) {
489+ assert.Equal(t, int64(1024*1024*1024), int64(DefaultMaxBlobSize))
490+}
491+
492+func TestHandler_BlobUpload_PATCH_MaxBlobSizeExceededReturnsSizeInvalidAndCancels(t *testing.T) {
493+ registrySvc := inmocks.NewMockRegistryService(t)
494+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10)
495+
496+ registrySvc.EXPECT().AppendBlobChunk(
497+ mock.Anything,
498+ "myapp",
499+ "550e8400-e29b-41d4-a716-446655440000",
500+ mock.Anything,
501+ int64(6),
502+ int64(10),
503+ ).Return(int64(0), domain.ErrBlobSizeExceeded)
504+ registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil)
505+
506+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("123456")))
507+ rec := httptest.NewRecorder()
508+
509+ handler.ServeHTTP(rec, req)
510+
511+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
512+ assert.Contains(t, rec.Body.String(), "SIZE_INVALID")
513+}
514+
515+func TestHandler_BlobUpload_PATCH_ContentLengthTooLargeReturnsSizeInvalidAndCancels(t *testing.T) {
516+ registrySvc := inmocks.NewMockRegistryService(t)
517+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10)
518+
519+ registrySvc.EXPECT().AppendBlobChunk(
520+ mock.Anything,
521+ "myapp",
522+ "550e8400-e29b-41d4-a716-446655440000",
523+ mock.Anything,
524+ int64(11),
525+ int64(10),
526+ ).Return(int64(0), domain.ErrBlobSizeExceeded)
527+ registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil)
528+
529+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("12345678901")))
530+ rec := httptest.NewRecorder()
531+
532+ handler.ServeHTTP(rec, req)
533+
534+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
535+ assert.Contains(t, rec.Body.String(), "SIZE_INVALID")
536+}
537+
538+func TestHandler_BlobUpload_PUT_FinalizeUnderMaxBlobSize(t *testing.T) {
539+ registrySvc := inmocks.NewMockRegistryService(t)
540+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10)
541+
542+ chunkData := []byte("1234567890")
543+ registrySvc.EXPECT().AppendBlobChunk(
544+ mock.Anything,
545+ "myapp",
546+ "550e8400-e29b-41d4-a716-446655440000",
547+ mock.Anything,
548+ int64(len(chunkData)),
549+ int64(10),
550+ ).Return(int64(len(chunkData)), nil)
551+ registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
552+
553+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
554+ rec := httptest.NewRecorder()
555+
556+ handler.ServeHTTP(rec, req)
557+
558+ assert.Equal(t, http.StatusCreated, rec.Code)
559+ assert.Equal(t, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", rec.Header().Get("Docker-Content-Digest"))
560+}
@@ -293,7 +293,7 @@ func TestHandler_BlobUpload_PATCH(t *testing.T) {
293 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)293 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
294 294
295 chunkData := []byte("chunk content")295 chunkData := []byte("chunk content")
296- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)296+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
297 297
298 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))298 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
299 rec := httptest.NewRecorder()299 rec := httptest.NewRecorder()
@@ -310,7 +310,7 @@ func TestHandler_BlobUpload_PUT_Finalize(t *testing.T) {
310 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)310 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
311 311
312 chunkData := []byte("final chunk")312 chunkData := []byte("final chunk")
313- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)313+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
314 registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)314 registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
315 315
316 req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))316 req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
@@ -432,7 +432,7 @@ func TestHandler_BlobUpload_PATCH_ReturnsDockerUploadUUID(t *testing.T) {
432 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)432 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
433 433
434 chunkData := []byte("chunk content")434 chunkData := []byte("chunk content")
435- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)435+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
436 436
437 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))437 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
438 rec := httptest.NewRecorder()438 rec := httptest.NewRecorder()
@@ -453,8 +453,8 @@ func TestHandler_BlobUpload_PATCH_RespectsConfiguredMaxBlobChunkSize(t *testing.
453 453
454 // The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk.454 // The handler wraps r.Body in MaxBytesReader and passes it to AppendBlobChunk.
455 // The mock must read the body to trigger MaxBytesError, which the handler detects.455 // The mock must read the body to trigger MaxBytesError, which the handler detects.
456- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).456+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).
457- Run(func(_ context.Context, _ string, _ string, data io.Reader) {457+ Run(func(_ context.Context, _ string, _ string, data io.Reader, _ int64, _ int64) {
458 // Drain the reader to trigger MaxBytesError458 // Drain the reader to trigger MaxBytesError
459 _, _ = io.ReadAll(data)459 _, _ = io.ReadAll(data)
460 }).460 }).
@@ -473,7 +473,7 @@ func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
473 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)473 handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize)
474 474
475 chunkData := []byte("chunk content")475 chunkData := []byte("chunk content")
476- registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything).Return(int64(len(chunkData)), nil)476+ registrySvc.EXPECT().AppendBlobChunk(mock.Anything, "myapp", "550e8400-e29b-41d4-a716-446655440000", mock.Anything, mock.Anything, mock.Anything).Return(int64(len(chunkData)), nil)
477 477
478 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))478 req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader(chunkData))
479 rec := httptest.NewRecorder()479 rec := httptest.NewRecorder()
@@ -484,3 +484,77 @@ func TestHandler_BlobUpload_PATCH_ReturnsRangeHeader(t *testing.T) {
484 // OCI spec: Range header should reflect total received bytes484 // OCI spec: Range header should reflect total received bytes
485 assert.Equal(t, "0-12", rec.Header().Get("Range"))485 assert.Equal(t, "0-12", rec.Header().Get("Range"))
486 }486 }
487+
488+func TestDefaultMaxBlobSize_Is1GB(t *testing.T) {
489+ assert.Equal(t, int64(1024*1024*1024), int64(DefaultMaxBlobSize))
490+}
491+
492+func TestHandler_BlobUpload_PATCH_MaxBlobSizeExceededReturnsSizeInvalidAndCancels(t *testing.T) {
493+ registrySvc := inmocks.NewMockRegistryService(t)
494+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10)
495+
496+ registrySvc.EXPECT().AppendBlobChunk(
497+ mock.Anything,
498+ "myapp",
499+ "550e8400-e29b-41d4-a716-446655440000",
500+ mock.Anything,
501+ int64(6),
502+ int64(10),
503+ ).Return(int64(0), domain.ErrBlobSizeExceeded)
504+ registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil)
505+
506+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("123456")))
507+ rec := httptest.NewRecorder()
508+
509+ handler.ServeHTTP(rec, req)
510+
511+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
512+ assert.Contains(t, rec.Body.String(), "SIZE_INVALID")
513+}
514+
515+func TestHandler_BlobUpload_PATCH_ContentLengthTooLargeReturnsSizeInvalidAndCancels(t *testing.T) {
516+ registrySvc := inmocks.NewMockRegistryService(t)
517+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10)
518+
519+ registrySvc.EXPECT().AppendBlobChunk(
520+ mock.Anything,
521+ "myapp",
522+ "550e8400-e29b-41d4-a716-446655440000",
523+ mock.Anything,
524+ int64(11),
525+ int64(10),
526+ ).Return(int64(0), domain.ErrBlobSizeExceeded)
527+ registrySvc.EXPECT().CancelUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000").Return(nil)
528+
529+ req := httptest.NewRequest("PATCH", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000", bytes.NewReader([]byte("12345678901")))
530+ rec := httptest.NewRecorder()
531+
532+ handler.ServeHTTP(rec, req)
533+
534+ assert.Equal(t, http.StatusRequestEntityTooLarge, rec.Code)
535+ assert.Contains(t, rec.Body.String(), "SIZE_INVALID")
536+}
537+
538+func TestHandler_BlobUpload_PUT_FinalizeUnderMaxBlobSize(t *testing.T) {
539+ registrySvc := inmocks.NewMockRegistryService(t)
540+ handler := NewHandler(registrySvc, testLogger(), DefaultMaxBlobChunkSize, 10)
541+
542+ chunkData := []byte("1234567890")
543+ registrySvc.EXPECT().AppendBlobChunk(
544+ mock.Anything,
545+ "myapp",
546+ "550e8400-e29b-41d4-a716-446655440000",
547+ mock.Anything,
548+ int64(len(chunkData)),
549+ int64(10),
550+ ).Return(int64(len(chunkData)), nil)
551+ registrySvc.EXPECT().FinishUpload(mock.Anything, "550e8400-e29b-41d4-a716-446655440000", "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4").Return(nil)
552+
553+ req := httptest.NewRequest("PUT", "/v2/myapp/blobs/uploads/550e8400-e29b-41d4-a716-446655440000?digest=sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", bytes.NewReader(chunkData))
554+ rec := httptest.NewRecorder()
555+
556+ handler.ServeHTTP(rec, req)
557+
558+ assert.Equal(t, http.StatusCreated, rec.Code)
559+ assert.Equal(t, "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4", rec.Header().Get("Docker-Content-Digest"))
560+}
M internal/adapters/out/filesystem/blob.go +24 -2
@@ -17,6 +17,7 @@ import (
1717 "github.com/bnema/zerowrap"
1818 "github.com/google/uuid"
1919
20+ "github.com/bnema/gordon/internal/domain"
2021 "github.com/bnema/gordon/pkg/validation"
2122 )
2223
@@ -254,7 +255,7 @@ func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
254255 }
255256
256257 // AppendBlobChunk appends data to an in-progress upload.
257-func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) {
258+func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) {
258259 uploadPath, err := s.getUploadPath(uuid)
259260 if err != nil {
260261 return 0, fmt.Errorf("invalid upload path: %w", err)
@@ -274,12 +275,33 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64,
274275 }
275276 defer file.Close()
276277
278+ fi, err := file.Stat()
279+ if err != nil {
280+ return 0, fmt.Errorf("failed to get file info: %w", err)
281+ }
282+ originalSize := fi.Size()
283+
284+ if maxBlobSize > 0 {
285+ remaining := maxBlobSize - originalSize
286+ if remaining < 0 || (contentLength >= 0 && contentLength > remaining) {
287+ return 0, domain.ErrBlobSizeExceeded
288+ }
289+ data = io.LimitReader(data, remaining+1)
290+ }
291+
277292 written, err := io.Copy(file, data)
278293 if err != nil {
279294 return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
280295 }
281296
282- fi, err := file.Stat()
297+ if maxBlobSize > 0 && originalSize+written > maxBlobSize {
298+ if err := file.Truncate(originalSize); err != nil {
299+ return 0, fmt.Errorf("failed to truncate oversized upload: %w", err)
300+ }
301+ return 0, domain.ErrBlobSizeExceeded
302+ }
303+
304+ fi, err = file.Stat()
283305 if err != nil {
284306 return 0, fmt.Errorf("failed to get file info: %w", err)
285307 }
@@ -17,6 +17,7 @@ import (
17 "github.com/bnema/zerowrap"17 "github.com/bnema/zerowrap"
18 "github.com/google/uuid"18 "github.com/google/uuid"
19 19
20+ "github.com/bnema/gordon/internal/domain"
20 "github.com/bnema/gordon/pkg/validation"21 "github.com/bnema/gordon/pkg/validation"
21 )22 )
22 23
@@ -254,7 +255,7 @@ func (s *BlobStorage) StartBlobUpload(name string) (string, error) {
254 }255 }
255 256
256 // AppendBlobChunk appends data to an in-progress upload.257 // AppendBlobChunk appends data to an in-progress upload.
257-func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64, error) {258+func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) {
258 uploadPath, err := s.getUploadPath(uuid)259 uploadPath, err := s.getUploadPath(uuid)
259 if err != nil {260 if err != nil {
260 return 0, fmt.Errorf("invalid upload path: %w", err)261 return 0, fmt.Errorf("invalid upload path: %w", err)
@@ -274,12 +275,33 @@ func (s *BlobStorage) AppendBlobChunk(name, uuid string, data io.Reader) (int64,
274 }275 }
275 defer file.Close()276 defer file.Close()
276 277
278+ fi, err := file.Stat()
279+ if err != nil {
280+ return 0, fmt.Errorf("failed to get file info: %w", err)
281+ }
282+ originalSize := fi.Size()
283+
284+ if maxBlobSize > 0 {
285+ remaining := maxBlobSize - originalSize
286+ if remaining < 0 || (contentLength >= 0 && contentLength > remaining) {
287+ return 0, domain.ErrBlobSizeExceeded
288+ }
289+ data = io.LimitReader(data, remaining+1)
290+ }
291+
277 written, err := io.Copy(file, data)292 written, err := io.Copy(file, data)
278 if err != nil {293 if err != nil {
279 return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)294 return 0, fmt.Errorf("failed to write chunk to upload file: %w", err)
280 }295 }
281 296
282- fi, err := file.Stat()297+ if maxBlobSize > 0 && originalSize+written > maxBlobSize {
298+ if err := file.Truncate(originalSize); err != nil {
299+ return 0, fmt.Errorf("failed to truncate oversized upload: %w", err)
300+ }
301+ return 0, domain.ErrBlobSizeExceeded
302+ }
303+
304+ fi, err = file.Stat()
283 if err != nil {305 if err != nil {
284 return 0, fmt.Errorf("failed to get file info: %w", err)306 return 0, fmt.Errorf("failed to get file info: %w", err)
285 }307 }
M internal/adapters/out/filesystem/blob_test.go +95 -12
@@ -4,6 +4,7 @@ import (
44 "bytes"
55 "crypto/sha256"
66 "encoding/hex"
7+ "errors"
78 "io"
89 "os"
910 "path/filepath"
@@ -13,6 +14,8 @@ import (
1314 "github.com/bnema/zerowrap"
1415 "github.com/stretchr/testify/assert"
1516 "github.com/stretchr/testify/require"
17+
18+ "github.com/bnema/gordon/internal/domain"
1619 )
1720
1821 const (
@@ -26,6 +29,15 @@ func testLogger() zerowrap.Logger {
2629 return zerowrap.Default()
2730 }
2831
32+type readFailer struct {
33+ read bool
34+}
35+
36+func (r *readFailer) Read(_ []byte) (int, error) {
37+ r.read = true
38+ return 0, io.ErrUnexpectedEOF
39+}
40+
2941 func TestNewBlobStorage(t *testing.T) {
3042 tmpDir := t.TempDir()
3143 log := testLogger()
@@ -280,13 +292,13 @@ func TestBlobStorage_AppendBlobChunk(t *testing.T) {
280292
281293 // Append first chunk
282294 chunk1 := []byte("first chunk")
283- size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1))
295+ size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1), -1, 0)
284296 require.NoError(t, err)
285297 assert.Equal(t, int64(len(chunk1)), size1)
286298
287299 // Append second chunk
288300 chunk2 := []byte(" second chunk")
289- size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2))
301+ size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2), -1, 0)
290302 require.NoError(t, err)
291303 assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2)
292304 }
@@ -298,12 +310,83 @@ func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) {
298310 storage, err := NewBlobStorage(tmpDir, log)
299311 require.NoError(t, err)
300312
301- _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", bytes.NewReader([]byte("data")))
313+ _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", bytes.NewReader([]byte("data")), -1, 100)
302314
303315 assert.Error(t, err)
304316 assert.Contains(t, err.Error(), "upload not found")
305317 }
306318
319+func TestBlobStorage_AppendBlobChunk_MaxBlobSize(t *testing.T) {
320+ tmpDir := t.TempDir()
321+ log := testLogger()
322+
323+ storage, err := NewBlobStorage(tmpDir, log)
324+ require.NoError(t, err)
325+
326+ uuid, err := storage.StartBlobUpload("myapp")
327+ require.NoError(t, err)
328+
329+ size, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), -1, 10)
330+ require.NoError(t, err)
331+ assert.Equal(t, int64(5), size)
332+
333+ size, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("67890")), -1, 10)
334+ require.NoError(t, err)
335+ assert.Equal(t, int64(10), size)
336+
337+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("x")), -1, 10)
338+ require.Error(t, err)
339+ assert.True(t, errors.Is(err, domain.ErrBlobSizeExceeded))
340+
341+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
342+ info, statErr := os.Stat(uploadPath)
343+ require.NoError(t, statErr)
344+ assert.Equal(t, int64(10), info.Size())
345+}
346+
347+func TestBlobStorage_AppendBlobChunk_MaxBlobSizeContentLengthTooLargeDoesNotRead(t *testing.T) {
348+ tmpDir := t.TempDir()
349+ log := testLogger()
350+
351+ storage, err := NewBlobStorage(tmpDir, log)
352+ require.NoError(t, err)
353+
354+ uuid, err := storage.StartBlobUpload("myapp")
355+ require.NoError(t, err)
356+
357+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), 5, 10)
358+ require.NoError(t, err)
359+
360+ reader := &readFailer{}
361+ _, err = storage.AppendBlobChunk("myapp", uuid, reader, 6, 10)
362+ require.Error(t, err)
363+ assert.ErrorIs(t, err, domain.ErrBlobSizeExceeded)
364+ assert.False(t, reader.read)
365+}
366+
367+func TestBlobStorage_AppendBlobChunk_MaxBlobSizeOverflowMidReadTruncates(t *testing.T) {
368+ tmpDir := t.TempDir()
369+ log := testLogger()
370+
371+ storage, err := NewBlobStorage(tmpDir, log)
372+ require.NoError(t, err)
373+
374+ uuid, err := storage.StartBlobUpload("myapp")
375+ require.NoError(t, err)
376+
377+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), -1, 10)
378+ require.NoError(t, err)
379+
380+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("6789012345")), -1, 10)
381+ require.Error(t, err)
382+ assert.True(t, errors.Is(err, domain.ErrBlobSizeExceeded))
383+
384+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
385+ info, statErr := os.Stat(uploadPath)
386+ require.NoError(t, statErr)
387+ assert.Equal(t, int64(5), info.Size())
388+}
389+
307390 func TestBlobStorage_GetBlobUpload(t *testing.T) {
308391 tmpDir := t.TempDir()
309392 log := testLogger()
@@ -350,7 +433,7 @@ func TestBlobStorage_FinishBlobUpload(t *testing.T) {
350433
351434 // Append data - content that hashes to testDigest1
352435 blobData := []byte("complete blob content")
353- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))
436+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
354437 require.NoError(t, err)
355438
356439 // Finish upload - use testDigest1 (validation will pass, but hash won't match)
@@ -477,7 +560,7 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) {
477560
478561 var totalSize int64
479562 for _, chunk := range chunks {
480- size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk))
563+ size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk), -1, 0)
481564 require.NoError(t, err)
482565 totalSize = size
483566 }
@@ -501,7 +584,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
501584 require.NoError(t, err)
502585
503586 initialData := []byte("initial-")
504- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData))
587+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData), -1, 0)
505588 require.NoError(t, err)
506589
507590 appendChunk := []byte("chunk")
@@ -516,7 +599,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
516599 go func() {
517600 defer wg.Done()
518601 <-appendReady
519- _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk))
602+ _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk), -1, 0)
520603 appendErrs <- appendErr
521604 }()
522605 }
@@ -570,7 +653,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
570653 assert.Equal(t, fullyAppended, blobData)
571654 }
572655
573- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")))
656+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")), -1, 0)
574657 assert.Error(t, err)
575658 assert.Contains(t, err.Error(), "upload not found")
576659 }
@@ -586,13 +669,13 @@ func TestBlobStorage_AppendAfterFinalize(t *testing.T) {
586669 require.NoError(t, err)
587670
588671 blobData := []byte("finalized content")
589- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))
672+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
590673 require.NoError(t, err)
591674
592675 err = storage.FinishBlobUpload(uuid, digestForData(blobData))
593676 require.NoError(t, err)
594677
595- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")))
678+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")), -1, 0)
596679 assert.Error(t, err)
597680 assert.Contains(t, err.Error(), "upload not found")
598681 }
@@ -608,7 +691,7 @@ func TestBlobStorage_FailedFinalizeRetryable(t *testing.T) {
608691 require.NoError(t, err)
609692
610693 blobData := []byte("retryable finalize")
611- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))
694+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
612695 require.NoError(t, err)
613696
614697 err = storage.FinishBlobUpload(uuid, testDigest1)
@@ -638,7 +721,7 @@ func TestBlobStorage_SequentialFlow(t *testing.T) {
638721 var blobData []byte
639722 for _, chunk := range chunks {
640723 blobData = append(blobData, chunk...)
641- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk))
724+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk), -1, 0)
642725 require.NoError(t, err)
643726 }
644727
@@ -4,6 +4,7 @@ import (
4 "bytes"4 "bytes"
5 "crypto/sha256"5 "crypto/sha256"
6 "encoding/hex"6 "encoding/hex"
7+ "errors"
7 "io"8 "io"
8 "os"9 "os"
9 "path/filepath"10 "path/filepath"
@@ -13,6 +14,8 @@ import (
13 "github.com/bnema/zerowrap"14 "github.com/bnema/zerowrap"
14 "github.com/stretchr/testify/assert"15 "github.com/stretchr/testify/assert"
15 "github.com/stretchr/testify/require"16 "github.com/stretchr/testify/require"
17+
18+ "github.com/bnema/gordon/internal/domain"
16 )19 )
17 20
18 const (21 const (
@@ -26,6 +29,15 @@ func testLogger() zerowrap.Logger {
26 return zerowrap.Default()29 return zerowrap.Default()
27 }30 }
28 31
32+type readFailer struct {
33+ read bool
34+}
35+
36+func (r *readFailer) Read(_ []byte) (int, error) {
37+ r.read = true
38+ return 0, io.ErrUnexpectedEOF
39+}
40+
29 func TestNewBlobStorage(t *testing.T) {41 func TestNewBlobStorage(t *testing.T) {
30 tmpDir := t.TempDir()42 tmpDir := t.TempDir()
31 log := testLogger()43 log := testLogger()
@@ -280,13 +292,13 @@ func TestBlobStorage_AppendBlobChunk(t *testing.T) {
280 292
281 // Append first chunk293 // Append first chunk
282 chunk1 := []byte("first chunk")294 chunk1 := []byte("first chunk")
283- size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1))295+ size1, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk1), -1, 0)
284 require.NoError(t, err)296 require.NoError(t, err)
285 assert.Equal(t, int64(len(chunk1)), size1)297 assert.Equal(t, int64(len(chunk1)), size1)
286 298
287 // Append second chunk299 // Append second chunk
288 chunk2 := []byte(" second chunk")300 chunk2 := []byte(" second chunk")
289- size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2))301+ size2, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk2), -1, 0)
290 require.NoError(t, err)302 require.NoError(t, err)
291 assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2)303 assert.Equal(t, int64(len(chunk1)+len(chunk2)), size2)
292 }304 }
@@ -298,12 +310,83 @@ func TestBlobStorage_AppendBlobChunk_NotFound(t *testing.T) {
298 storage, err := NewBlobStorage(tmpDir, log)310 storage, err := NewBlobStorage(tmpDir, log)
299 require.NoError(t, err)311 require.NoError(t, err)
300 312
301- _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", bytes.NewReader([]byte("data")))313+ _, err = storage.AppendBlobChunk("myapp", "00000000-0000-4000-a000-000000000000", bytes.NewReader([]byte("data")), -1, 100)
302 314
303 assert.Error(t, err)315 assert.Error(t, err)
304 assert.Contains(t, err.Error(), "upload not found")316 assert.Contains(t, err.Error(), "upload not found")
305 }317 }
306 318
319+func TestBlobStorage_AppendBlobChunk_MaxBlobSize(t *testing.T) {
320+ tmpDir := t.TempDir()
321+ log := testLogger()
322+
323+ storage, err := NewBlobStorage(tmpDir, log)
324+ require.NoError(t, err)
325+
326+ uuid, err := storage.StartBlobUpload("myapp")
327+ require.NoError(t, err)
328+
329+ size, err := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), -1, 10)
330+ require.NoError(t, err)
331+ assert.Equal(t, int64(5), size)
332+
333+ size, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("67890")), -1, 10)
334+ require.NoError(t, err)
335+ assert.Equal(t, int64(10), size)
336+
337+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("x")), -1, 10)
338+ require.Error(t, err)
339+ assert.True(t, errors.Is(err, domain.ErrBlobSizeExceeded))
340+
341+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
342+ info, statErr := os.Stat(uploadPath)
343+ require.NoError(t, statErr)
344+ assert.Equal(t, int64(10), info.Size())
345+}
346+
347+func TestBlobStorage_AppendBlobChunk_MaxBlobSizeContentLengthTooLargeDoesNotRead(t *testing.T) {
348+ tmpDir := t.TempDir()
349+ log := testLogger()
350+
351+ storage, err := NewBlobStorage(tmpDir, log)
352+ require.NoError(t, err)
353+
354+ uuid, err := storage.StartBlobUpload("myapp")
355+ require.NoError(t, err)
356+
357+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), 5, 10)
358+ require.NoError(t, err)
359+
360+ reader := &readFailer{}
361+ _, err = storage.AppendBlobChunk("myapp", uuid, reader, 6, 10)
362+ require.Error(t, err)
363+ assert.ErrorIs(t, err, domain.ErrBlobSizeExceeded)
364+ assert.False(t, reader.read)
365+}
366+
367+func TestBlobStorage_AppendBlobChunk_MaxBlobSizeOverflowMidReadTruncates(t *testing.T) {
368+ tmpDir := t.TempDir()
369+ log := testLogger()
370+
371+ storage, err := NewBlobStorage(tmpDir, log)
372+ require.NoError(t, err)
373+
374+ uuid, err := storage.StartBlobUpload("myapp")
375+ require.NoError(t, err)
376+
377+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("12345")), -1, 10)
378+ require.NoError(t, err)
379+
380+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("6789012345")), -1, 10)
381+ require.Error(t, err)
382+ assert.True(t, errors.Is(err, domain.ErrBlobSizeExceeded))
383+
384+ uploadPath := filepath.Join(tmpDir, "uploads", uuid)
385+ info, statErr := os.Stat(uploadPath)
386+ require.NoError(t, statErr)
387+ assert.Equal(t, int64(5), info.Size())
388+}
389+
307 func TestBlobStorage_GetBlobUpload(t *testing.T) {390 func TestBlobStorage_GetBlobUpload(t *testing.T) {
308 tmpDir := t.TempDir()391 tmpDir := t.TempDir()
309 log := testLogger()392 log := testLogger()
@@ -350,7 +433,7 @@ func TestBlobStorage_FinishBlobUpload(t *testing.T) {
350 433
351 // Append data - content that hashes to testDigest1434 // Append data - content that hashes to testDigest1
352 blobData := []byte("complete blob content")435 blobData := []byte("complete blob content")
353- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))436+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
354 require.NoError(t, err)437 require.NoError(t, err)
355 438
356 // Finish upload - use testDigest1 (validation will pass, but hash won't match)439 // Finish upload - use testDigest1 (validation will pass, but hash won't match)
@@ -477,7 +560,7 @@ func TestBlobStorage_CompleteUploadFlow(t *testing.T) {
477 560
478 var totalSize int64561 var totalSize int64
479 for _, chunk := range chunks {562 for _, chunk := range chunks {
480- size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk))563+ size, err := storage.AppendBlobChunk(repoName, uuid, bytes.NewReader(chunk), -1, 0)
481 require.NoError(t, err)564 require.NoError(t, err)
482 totalSize = size565 totalSize = size
483 }566 }
@@ -501,7 +584,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
501 require.NoError(t, err)584 require.NoError(t, err)
502 585
503 initialData := []byte("initial-")586 initialData := []byte("initial-")
504- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData))587+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(initialData), -1, 0)
505 require.NoError(t, err)588 require.NoError(t, err)
506 589
507 appendChunk := []byte("chunk")590 appendChunk := []byte("chunk")
@@ -516,7 +599,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
516 go func() {599 go func() {
517 defer wg.Done()600 defer wg.Done()
518 <-appendReady601 <-appendReady
519- _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk))602+ _, appendErr := storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(appendChunk), -1, 0)
520 appendErrs <- appendErr603 appendErrs <- appendErr
521 }()604 }()
522 }605 }
@@ -570,7 +653,7 @@ func TestBlobStorage_ConcurrentAppendAndFinalize(t *testing.T) {
570 assert.Equal(t, fullyAppended, blobData)653 assert.Equal(t, fullyAppended, blobData)
571 }654 }
572 655
573- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")))656+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("after")), -1, 0)
574 assert.Error(t, err)657 assert.Error(t, err)
575 assert.Contains(t, err.Error(), "upload not found")658 assert.Contains(t, err.Error(), "upload not found")
576 }659 }
@@ -586,13 +669,13 @@ func TestBlobStorage_AppendAfterFinalize(t *testing.T) {
586 require.NoError(t, err)669 require.NoError(t, err)
587 670
588 blobData := []byte("finalized content")671 blobData := []byte("finalized content")
589- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))672+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
590 require.NoError(t, err)673 require.NoError(t, err)
591 674
592 err = storage.FinishBlobUpload(uuid, digestForData(blobData))675 err = storage.FinishBlobUpload(uuid, digestForData(blobData))
593 require.NoError(t, err)676 require.NoError(t, err)
594 677
595- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")))678+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader([]byte("more")), -1, 0)
596 assert.Error(t, err)679 assert.Error(t, err)
597 assert.Contains(t, err.Error(), "upload not found")680 assert.Contains(t, err.Error(), "upload not found")
598 }681 }
@@ -608,7 +691,7 @@ func TestBlobStorage_FailedFinalizeRetryable(t *testing.T) {
608 require.NoError(t, err)691 require.NoError(t, err)
609 692
610 blobData := []byte("retryable finalize")693 blobData := []byte("retryable finalize")
611- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData))694+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(blobData), -1, 0)
612 require.NoError(t, err)695 require.NoError(t, err)
613 696
614 err = storage.FinishBlobUpload(uuid, testDigest1)697 err = storage.FinishBlobUpload(uuid, testDigest1)
@@ -638,7 +721,7 @@ func TestBlobStorage_SequentialFlow(t *testing.T) {
638 var blobData []byte721 var blobData []byte
639 for _, chunk := range chunks {722 for _, chunk := range chunks {
640 blobData = append(blobData, chunk...)723 blobData = append(blobData, chunk...)
641- _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk))724+ _, err = storage.AppendBlobChunk("myapp", uuid, bytes.NewReader(chunk), -1, 0)
642 require.NoError(t, err)725 require.NoError(t, err)
643 }726 }
644 727
M internal/app/run.go +16 -2
@@ -96,6 +96,7 @@ type Config struct {
9696 DataDir string `mapstructure:"data_dir"`
9797 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
9898 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
99+ MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"
99100 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
100101 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
101102 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
@@ -215,6 +216,7 @@ type services struct {
215216 previewService *preview.Service
216217 envDir string
217218 maxBlobChunkSize int64
219+ maxBlobSize int64
218220 caAdapter *pkiadapter.CA
219221 pkiSvc *pkiusecase.Service
220222 reloadCoordinator *reloadCoordinator
@@ -526,6 +528,7 @@ func (si *serviceInit) initRuntimeAndProxy() error {
526528 return err
527529 }
528530 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
531+ si.svc.maxBlobSize = proxyCfg.maxBlobSize
529532 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
530533
531534 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
@@ -1349,6 +1352,7 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
13491352 type proxyConfigResult struct {
13501353 proxyConfig proxy.Config
13511354 maxBlobChunkSize int64
1355+ maxBlobSize int64
13521356 }
13531357
13541358 type configWatcher interface {
@@ -1477,6 +1481,15 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
14771481 maxBlobChunkSize = parsedSize
14781482 }
14791483
1484+ maxBlobSize := int64(registry.DefaultMaxBlobSize)
1485+ if cfg.Server.MaxBlobSize != "" {
1486+ parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobSize)
1487+ if err != nil {
1488+ return nil, log.WrapErrWithFields(err, "invalid server.max_blob_size configuration", map[string]any{"value": cfg.Server.MaxBlobSize})
1489+ }
1490+ maxBlobSize = parsedSize
1491+ }
1492+
14801493 maxProxyResponseSize := int64(1 << 30) // 1GB default
14811494 if cfg.Server.MaxProxyResponseSize != "" {
14821495 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
@@ -1501,6 +1514,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
15011514 MaxConcurrentConns: maxConcurrentConns,
15021515 },
15031516 maxBlobChunkSize: maxBlobChunkSize,
1517+ maxBlobSize: maxBlobSize,
15041518 }, nil
15051519 }
15061520
@@ -1758,8 +1772,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
17581772 // This ensures consistent IP extraction across logging, rate limiting, and auth.
17591773 trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
17601774
1761- // Registry handler (unchanged)
1762- registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)
1775+ // Registry handler
1776+ registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize, svc.maxBlobSize)
17631777 registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
17641778 svc,
17651779 cfg,
@@ -96,6 +96,7 @@ type Config struct {
96 DataDir string `mapstructure:"data_dir"`96 DataDir string `mapstructure:"data_dir"`
97 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"97 MaxProxyBodySize string `mapstructure:"max_proxy_body_size"` // e.g., "512MB", "1GB"
98 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"98 MaxBlobChunkSize string `mapstructure:"max_blob_chunk_size"` // e.g., "512MB", "1GB"
99+ MaxBlobSize string `mapstructure:"max_blob_size"` // e.g., "1GB", "2GB"
99 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit100 MaxProxyResponseSize string `mapstructure:"max_proxy_response_size"` // e.g., "1GB", "0" for no limit
100 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`101 MaxConcurrentConns int `mapstructure:"max_concurrent_connections"`
101 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`102 RegistryAllowedIPs []string `mapstructure:"registry_allowed_ips"`
@@ -215,6 +216,7 @@ type services struct {
215 previewService *preview.Service216 previewService *preview.Service
216 envDir string217 envDir string
217 maxBlobChunkSize int64218 maxBlobChunkSize int64
219+ maxBlobSize int64
218 caAdapter *pkiadapter.CA220 caAdapter *pkiadapter.CA
219 pkiSvc *pkiusecase.Service221 pkiSvc *pkiusecase.Service
220 reloadCoordinator *reloadCoordinator222 reloadCoordinator *reloadCoordinator
@@ -526,6 +528,7 @@ func (si *serviceInit) initRuntimeAndProxy() error {
526 return err528 return err
527 }529 }
528 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize530 si.svc.maxBlobChunkSize = proxyCfg.maxBlobChunkSize
531+ si.svc.maxBlobSize = proxyCfg.maxBlobSize
529 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)532 si.svc.proxySvc = proxy.NewService(si.svc.runtime, si.svc.containerSvc, si.svc.configSvc, proxyCfg.proxyConfig)
530 533
531 // Wire synchronous proxy cache invalidation for zero-downtime deployments.534 // Wire synchronous proxy cache invalidation for zero-downtime deployments.
@@ -1349,6 +1352,7 @@ func loadSecret(ctx context.Context, backend domain.SecretsBackend, path, dataDi
1349 type proxyConfigResult struct {1352 type proxyConfigResult struct {
1350 proxyConfig proxy.Config1353 proxyConfig proxy.Config
1351 maxBlobChunkSize int641354 maxBlobChunkSize int64
1355+ maxBlobSize int64
1352 }1356 }
1353 1357
1354 type configWatcher interface {1358 type configWatcher interface {
@@ -1477,6 +1481,15 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1477 maxBlobChunkSize = parsedSize1481 maxBlobChunkSize = parsedSize
1478 }1482 }
1479 1483
1484+ maxBlobSize := int64(registry.DefaultMaxBlobSize)
1485+ if cfg.Server.MaxBlobSize != "" {
1486+ parsedSize, err := bytesize.Parse(cfg.Server.MaxBlobSize)
1487+ if err != nil {
1488+ return nil, log.WrapErrWithFields(err, "invalid server.max_blob_size configuration", map[string]any{"value": cfg.Server.MaxBlobSize})
1489+ }
1490+ maxBlobSize = parsedSize
1491+ }
1492+
1480 maxProxyResponseSize := int64(1 << 30) // 1GB default1493 maxProxyResponseSize := int64(1 << 30) // 1GB default
1481 if cfg.Server.MaxProxyResponseSize != "" {1494 if cfg.Server.MaxProxyResponseSize != "" {
1482 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)1495 parsedSize, err := bytesize.Parse(cfg.Server.MaxProxyResponseSize)
@@ -1501,6 +1514,7 @@ func buildProxyConfig(cfg Config, log zerowrap.Logger) (*proxyConfigResult, erro
1501 MaxConcurrentConns: maxConcurrentConns,1514 MaxConcurrentConns: maxConcurrentConns,
1502 },1515 },
1503 maxBlobChunkSize: maxBlobChunkSize,1516 maxBlobChunkSize: maxBlobChunkSize,
1517+ maxBlobSize: maxBlobSize,
1504 }, nil1518 }, nil
1505 }1519 }
1506 1520
@@ -1758,8 +1772,8 @@ func createHTTPHandlers(svc *services, cfg Config, log zerowrap.Logger, accessWr
1758 // This ensures consistent IP extraction across logging, rate limiting, and auth.1772 // This ensures consistent IP extraction across logging, rate limiting, and auth.
1759 trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)1773 trustedNets := httphelper.ParseTrustedProxies(cfg.API.RateLimit.TrustedProxies)
1760 1774
1761- // Registry handler (unchanged)1775+ // Registry handler
1762- registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize)1776+ registryHandler := registry.NewHandler(svc.registrySvc, log, svc.maxBlobChunkSize, svc.maxBlobSize)
1763 registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(1777 registryWithMiddleware, cidrAllowlistMiddleware, rateLimitMiddleware := buildRegistryHandlerWithMiddleware(
1764 svc,1778 svc,
1765 cfg,1779 cfg,
M internal/app/run_reload_test.go +19 -0
@@ -12,6 +12,7 @@ import (
1212 "github.com/stretchr/testify/assert"
1313 "github.com/stretchr/testify/require"
1414
15+ "github.com/bnema/gordon/internal/adapters/in/http/registry"
1516 "github.com/bnema/gordon/internal/domain"
1617 "github.com/bnema/gordon/internal/usecase/proxy"
1718 )
@@ -138,6 +139,24 @@ func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) {
138139 require.Equal(t, 0, coordinator.TriggerCalls())
139140 }
140141
142+func TestBuildProxyConfig_ParsesMaxBlobSizeWithoutChangingChunkDefault(t *testing.T) {
143+ cfg := Config{}
144+ cfg.Server.MaxBlobSize = "2GB"
145+
146+ result, err := buildProxyConfig(cfg, zerowrap.Default())
147+
148+ require.NoError(t, err)
149+ assert.Equal(t, int64(2<<30), result.maxBlobSize)
150+ assert.Equal(t, int64(registry.DefaultMaxBlobChunkSize), result.maxBlobChunkSize)
151+}
152+
153+func TestBuildProxyConfig_DefaultMaxBlobSize(t *testing.T) {
154+ result, err := buildProxyConfig(Config{}, zerowrap.Default())
155+
156+ require.NoError(t, err)
157+ assert.Equal(t, int64(registry.DefaultMaxBlobSize), result.maxBlobSize)
158+}
159+
141160 func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) {
142161 ctx := context.Background()
143162 v := viper.New()
@@ -12,6 +12,7 @@ import (
12 "github.com/stretchr/testify/assert"12 "github.com/stretchr/testify/assert"
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15+ "github.com/bnema/gordon/internal/adapters/in/http/registry"
15 "github.com/bnema/gordon/internal/domain"16 "github.com/bnema/gordon/internal/domain"
16 "github.com/bnema/gordon/internal/usecase/proxy"17 "github.com/bnema/gordon/internal/usecase/proxy"
17 )18 )
@@ -138,6 +139,24 @@ func TestSetupConfigHotReload_WatchCallbackInvokesCoordinator(t *testing.T) {
138 require.Equal(t, 0, coordinator.TriggerCalls())139 require.Equal(t, 0, coordinator.TriggerCalls())
139 }140 }
140 141
142+func TestBuildProxyConfig_ParsesMaxBlobSizeWithoutChangingChunkDefault(t *testing.T) {
143+ cfg := Config{}
144+ cfg.Server.MaxBlobSize = "2GB"
145+
146+ result, err := buildProxyConfig(cfg, zerowrap.Default())
147+
148+ require.NoError(t, err)
149+ assert.Equal(t, int64(2<<30), result.maxBlobSize)
150+ assert.Equal(t, int64(registry.DefaultMaxBlobChunkSize), result.maxBlobChunkSize)
151+}
152+
153+func TestBuildProxyConfig_DefaultMaxBlobSize(t *testing.T) {
154+ result, err := buildProxyConfig(Config{}, zerowrap.Default())
155+
156+ require.NoError(t, err)
157+ assert.Equal(t, int64(registry.DefaultMaxBlobSize), result.maxBlobSize)
158+}
159+
141 func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) {160 func TestReloadCoordinator_ApplyLoadedConfig_RebuildsProxyConfigAndPublishesEvent(t *testing.T) {
142 ctx := context.Background()161 ctx := context.Background()
143 v := viper.New()162 v := viper.New()
M internal/boundaries/in/mocks/mock_registry_service.go +24 -12
@@ -40,8 +40,8 @@ func (_m *MockRegistryService) EXPECT() *MockRegistryService_Expecter {
4040 }
4141
4242 // AppendBlobChunk provides a mock function for the type MockRegistryService
43-func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, data io.Reader) (int64, error) {
44- ret := _mock.Called(ctx, name, uuid, data)
43+func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error) {
44+ ret := _mock.Called(ctx, name, uuid, data, contentLength, maxBlobSize)
4545
4646 if len(ret) == 0 {
4747 panic("no return value specified for AppendBlobChunk")
@@ -49,16 +49,16 @@ func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name stri
4949
5050 var r0 int64
5151 var r1 error
52- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) (int64, error)); ok {
53- return returnFunc(ctx, name, uuid, data)
52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, int64, int64) (int64, error)); ok {
53+ return returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize)
5454 }
55- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) int64); ok {
56- r0 = returnFunc(ctx, name, uuid, data)
55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, int64, int64) int64); ok {
56+ r0 = returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize)
5757 } else {
5858 r0 = ret.Get(0).(int64)
5959 }
60- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader) error); ok {
61- r1 = returnFunc(ctx, name, uuid, data)
60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader, int64, int64) error); ok {
61+ r1 = returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize)
6262 } else {
6363 r1 = ret.Error(1)
6464 }
@@ -75,11 +75,13 @@ type MockRegistryService_AppendBlobChunk_Call struct {
7575 // - name string
7676 // - uuid string
7777 // - data io.Reader
78-func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}) *MockRegistryService_AppendBlobChunk_Call {
79- return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data)}
78+// - contentLength int64
79+// - maxBlobSize int64
80+func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}, contentLength interface{}, maxBlobSize interface{}) *MockRegistryService_AppendBlobChunk_Call {
81+ return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data, contentLength, maxBlobSize)}
8082 }
8183
82-func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, data io.Reader)) *MockRegistryService_AppendBlobChunk_Call {
84+func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64)) *MockRegistryService_AppendBlobChunk_Call {
8385 _c.Call.Run(func(args mock.Arguments) {
8486 var arg0 context.Context
8587 if args[0] != nil {
@@ -97,11 +99,21 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Con
9799 if args[3] != nil {
98100 arg3 = args[3].(io.Reader)
99101 }
102+ var arg4 int64
103+ if args[4] != nil {
104+ arg4 = args[4].(int64)
105+ }
106+ var arg5 int64
107+ if args[5] != nil {
108+ arg5 = args[5].(int64)
109+ }
100110 run(
101111 arg0,
102112 arg1,
103113 arg2,
104114 arg3,
115+ arg4,
116+ arg5,
105117 )
106118 })
107119 return _c
@@ -112,7 +124,7 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Return(n int64, err error) *
112124 return _c
113125 }
114126
115-func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, data io.Reader) (int64, error)) *MockRegistryService_AppendBlobChunk_Call {
127+func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error)) *MockRegistryService_AppendBlobChunk_Call {
116128 _c.Call.Return(run)
117129 return _c
118130 }
@@ -40,8 +40,8 @@ func (_m *MockRegistryService) EXPECT() *MockRegistryService_Expecter {
40 }40 }
41 41
42 // AppendBlobChunk provides a mock function for the type MockRegistryService42 // AppendBlobChunk provides a mock function for the type MockRegistryService
43-func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, data io.Reader) (int64, error) {43+func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error) {
44- ret := _mock.Called(ctx, name, uuid, data)44+ ret := _mock.Called(ctx, name, uuid, data, contentLength, maxBlobSize)
45 45
46 if len(ret) == 0 {46 if len(ret) == 0 {
47 panic("no return value specified for AppendBlobChunk")47 panic("no return value specified for AppendBlobChunk")
@@ -49,16 +49,16 @@ func (_mock *MockRegistryService) AppendBlobChunk(ctx context.Context, name stri
49 49
50 var r0 int6450 var r0 int64
51 var r1 error51 var r1 error
52- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) (int64, error)); ok {52+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, int64, int64) (int64, error)); ok {
53- return returnFunc(ctx, name, uuid, data)53+ return returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize)
54 }54 }
55- if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader) int64); ok {55+ if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, io.Reader, int64, int64) int64); ok {
56- r0 = returnFunc(ctx, name, uuid, data)56+ r0 = returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize)
57 } else {57 } else {
58 r0 = ret.Get(0).(int64)58 r0 = ret.Get(0).(int64)
59 }59 }
60- if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader) error); ok {60+ if returnFunc, ok := ret.Get(1).(func(context.Context, string, string, io.Reader, int64, int64) error); ok {
61- r1 = returnFunc(ctx, name, uuid, data)61+ r1 = returnFunc(ctx, name, uuid, data, contentLength, maxBlobSize)
62 } else {62 } else {
63 r1 = ret.Error(1)63 r1 = ret.Error(1)
64 }64 }
@@ -75,11 +75,13 @@ type MockRegistryService_AppendBlobChunk_Call struct {
75 // - name string75 // - name string
76 // - uuid string76 // - uuid string
77 // - data io.Reader77 // - data io.Reader
78-func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}) *MockRegistryService_AppendBlobChunk_Call {78+// - contentLength int64
79- return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data)}79+// - maxBlobSize int64
80+func (_e *MockRegistryService_Expecter) AppendBlobChunk(ctx interface{}, name interface{}, uuid interface{}, data interface{}, contentLength interface{}, maxBlobSize interface{}) *MockRegistryService_AppendBlobChunk_Call {
81+ return &MockRegistryService_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", ctx, name, uuid, data, contentLength, maxBlobSize)}
80 }82 }
81 83
82-func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, data io.Reader)) *MockRegistryService_AppendBlobChunk_Call {84+func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64)) *MockRegistryService_AppendBlobChunk_Call {
83 _c.Call.Run(func(args mock.Arguments) {85 _c.Call.Run(func(args mock.Arguments) {
84 var arg0 context.Context86 var arg0 context.Context
85 if args[0] != nil {87 if args[0] != nil {
@@ -97,11 +99,21 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Run(run func(ctx context.Con
97 if args[3] != nil {99 if args[3] != nil {
98 arg3 = args[3].(io.Reader)100 arg3 = args[3].(io.Reader)
99 }101 }
102+ var arg4 int64
103+ if args[4] != nil {
104+ arg4 = args[4].(int64)
105+ }
106+ var arg5 int64
107+ if args[5] != nil {
108+ arg5 = args[5].(int64)
109+ }
100 run(110 run(
101 arg0,111 arg0,
102 arg1,112 arg1,
103 arg2,113 arg2,
104 arg3,114 arg3,
115+ arg4,
116+ arg5,
105 )117 )
106 })118 })
107 return _c119 return _c
@@ -112,7 +124,7 @@ func (_c *MockRegistryService_AppendBlobChunk_Call) Return(n int64, err error) *
112 return _c124 return _c
113 }125 }
114 126
115-func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, data io.Reader) (int64, error)) *MockRegistryService_AppendBlobChunk_Call {127+func (_c *MockRegistryService_AppendBlobChunk_Call) RunAndReturn(run func(ctx context.Context, name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error)) *MockRegistryService_AppendBlobChunk_Call {
116 _c.Call.Return(run)128 _c.Call.Return(run)
117 return _c129 return _c
118 }130 }
M internal/boundaries/in/registry.go +1 -1
@@ -22,7 +22,7 @@ type RegistryService interface {
2222
2323 // Upload operations
2424 StartUpload(ctx context.Context, name string) (string, error)
25- AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error)
25+ AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error)
2626 FinishUpload(ctx context.Context, uuid, digest string) error
2727 CancelUpload(ctx context.Context, uuid string) error
2828
@@ -22,7 +22,7 @@ type RegistryService interface {
22 22
23 // Upload operations23 // Upload operations
24 StartUpload(ctx context.Context, name string) (string, error)24 StartUpload(ctx context.Context, name string) (string, error)
25- AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error)25+ AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error)
26 FinishUpload(ctx context.Context, uuid, digest string) error26 FinishUpload(ctx context.Context, uuid, digest string) error
27 CancelUpload(ctx context.Context, uuid string) error27 CancelUpload(ctx context.Context, uuid string) error
28 28
M internal/boundaries/out/mocks/mock_blob_storage.go +24 -12
@@ -39,8 +39,8 @@ func (_m *MockBlobStorage) EXPECT() *MockBlobStorage_Expecter {
3939 }
4040
4141 // AppendBlobChunk provides a mock function for the type MockBlobStorage
42-func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader) (int64, error) {
43- ret := _mock.Called(name, uuid, data)
42+func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error) {
43+ ret := _mock.Called(name, uuid, data, contentLength, maxBlobSize)
4444
4545 if len(ret) == 0 {
4646 panic("no return value specified for AppendBlobChunk")
@@ -48,16 +48,16 @@ func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.
4848
4949 var r0 int64
5050 var r1 error
51- if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) (int64, error)); ok {
52- return returnFunc(name, uuid, data)
51+ if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader, int64, int64) (int64, error)); ok {
52+ return returnFunc(name, uuid, data, contentLength, maxBlobSize)
5353 }
54- if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) int64); ok {
55- r0 = returnFunc(name, uuid, data)
54+ if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader, int64, int64) int64); ok {
55+ r0 = returnFunc(name, uuid, data, contentLength, maxBlobSize)
5656 } else {
5757 r0 = ret.Get(0).(int64)
5858 }
59- if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader) error); ok {
60- r1 = returnFunc(name, uuid, data)
59+ if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader, int64, int64) error); ok {
60+ r1 = returnFunc(name, uuid, data, contentLength, maxBlobSize)
6161 } else {
6262 r1 = ret.Error(1)
6363 }
@@ -73,11 +73,13 @@ type MockBlobStorage_AppendBlobChunk_Call struct {
7373 // - name string
7474 // - uuid string
7575 // - data io.Reader
76-func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}) *MockBlobStorage_AppendBlobChunk_Call {
77- return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data)}
76+// - contentLength int64
77+// - maxBlobSize int64
78+func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}, contentLength interface{}, maxBlobSize interface{}) *MockBlobStorage_AppendBlobChunk_Call {
79+ return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data, contentLength, maxBlobSize)}
7880 }
7981
80-func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, data io.Reader)) *MockBlobStorage_AppendBlobChunk_Call {
82+func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64)) *MockBlobStorage_AppendBlobChunk_Call {
8183 _c.Call.Run(func(args mock.Arguments) {
8284 var arg0 string
8385 if args[0] != nil {
@@ -91,10 +93,20 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid s
9193 if args[2] != nil {
9294 arg2 = args[2].(io.Reader)
9395 }
96+ var arg3 int64
97+ if args[3] != nil {
98+ arg3 = args[3].(int64)
99+ }
100+ var arg4 int64
101+ if args[4] != nil {
102+ arg4 = args[4].(int64)
103+ }
94104 run(
95105 arg0,
96106 arg1,
97107 arg2,
108+ arg3,
109+ arg4,
98110 )
99111 })
100112 return _c
@@ -105,7 +117,7 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Return(n int64, err error) *Mock
105117 return _c
106118 }
107119
108-func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, data io.Reader) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call {
120+func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call {
109121 _c.Call.Return(run)
110122 return _c
111123 }
@@ -39,8 +39,8 @@ func (_m *MockBlobStorage) EXPECT() *MockBlobStorage_Expecter {
39 }39 }
40 40
41 // AppendBlobChunk provides a mock function for the type MockBlobStorage41 // AppendBlobChunk provides a mock function for the type MockBlobStorage
42-func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader) (int64, error) {42+func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error) {
43- ret := _mock.Called(name, uuid, data)43+ ret := _mock.Called(name, uuid, data, contentLength, maxBlobSize)
44 44
45 if len(ret) == 0 {45 if len(ret) == 0 {
46 panic("no return value specified for AppendBlobChunk")46 panic("no return value specified for AppendBlobChunk")
@@ -48,16 +48,16 @@ func (_mock *MockBlobStorage) AppendBlobChunk(name string, uuid string, data io.
48 48
49 var r0 int6449 var r0 int64
50 var r1 error50 var r1 error
51- if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) (int64, error)); ok {51+ if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader, int64, int64) (int64, error)); ok {
52- return returnFunc(name, uuid, data)52+ return returnFunc(name, uuid, data, contentLength, maxBlobSize)
53 }53 }
54- if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader) int64); ok {54+ if returnFunc, ok := ret.Get(0).(func(string, string, io.Reader, int64, int64) int64); ok {
55- r0 = returnFunc(name, uuid, data)55+ r0 = returnFunc(name, uuid, data, contentLength, maxBlobSize)
56 } else {56 } else {
57 r0 = ret.Get(0).(int64)57 r0 = ret.Get(0).(int64)
58 }58 }
59- if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader) error); ok {59+ if returnFunc, ok := ret.Get(1).(func(string, string, io.Reader, int64, int64) error); ok {
60- r1 = returnFunc(name, uuid, data)60+ r1 = returnFunc(name, uuid, data, contentLength, maxBlobSize)
61 } else {61 } else {
62 r1 = ret.Error(1)62 r1 = ret.Error(1)
63 }63 }
@@ -73,11 +73,13 @@ type MockBlobStorage_AppendBlobChunk_Call struct {
73 // - name string73 // - name string
74 // - uuid string74 // - uuid string
75 // - data io.Reader75 // - data io.Reader
76-func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}) *MockBlobStorage_AppendBlobChunk_Call {76+// - contentLength int64
77- return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data)}77+// - maxBlobSize int64
78+func (_e *MockBlobStorage_Expecter) AppendBlobChunk(name interface{}, uuid interface{}, data interface{}, contentLength interface{}, maxBlobSize interface{}) *MockBlobStorage_AppendBlobChunk_Call {
79+ return &MockBlobStorage_AppendBlobChunk_Call{Call: _e.mock.On("AppendBlobChunk", name, uuid, data, contentLength, maxBlobSize)}
78 }80 }
79 81
80-func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, data io.Reader)) *MockBlobStorage_AppendBlobChunk_Call {82+func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64)) *MockBlobStorage_AppendBlobChunk_Call {
81 _c.Call.Run(func(args mock.Arguments) {83 _c.Call.Run(func(args mock.Arguments) {
82 var arg0 string84 var arg0 string
83 if args[0] != nil {85 if args[0] != nil {
@@ -91,10 +93,20 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Run(run func(name string, uuid s
91 if args[2] != nil {93 if args[2] != nil {
92 arg2 = args[2].(io.Reader)94 arg2 = args[2].(io.Reader)
93 }95 }
96+ var arg3 int64
97+ if args[3] != nil {
98+ arg3 = args[3].(int64)
99+ }
100+ var arg4 int64
101+ if args[4] != nil {
102+ arg4 = args[4].(int64)
103+ }
94 run(104 run(
95 arg0,105 arg0,
96 arg1,106 arg1,
97 arg2,107 arg2,
108+ arg3,
109+ arg4,
98 )110 )
99 })111 })
100 return _c112 return _c
@@ -105,7 +117,7 @@ func (_c *MockBlobStorage_AppendBlobChunk_Call) Return(n int64, err error) *Mock
105 return _c117 return _c
106 }118 }
107 119
108-func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, data io.Reader) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call {120+func (_c *MockBlobStorage_AppendBlobChunk_Call) RunAndReturn(run func(name string, uuid string, data io.Reader, contentLength int64, maxBlobSize int64) (int64, error)) *MockBlobStorage_AppendBlobChunk_Call {
109 _c.Call.Return(run)121 _c.Call.Return(run)
110 return _c122 return _c
111 }123 }
M internal/boundaries/out/storage.go +1 -1
@@ -30,7 +30,7 @@ type BlobStorage interface {
3030 StartBlobUpload(name string) (string, error)
3131
3232 // AppendBlobChunk appends data to an in-progress upload.
33- AppendBlobChunk(name, uuid string, data io.Reader) (int64, error)
33+ AppendBlobChunk(name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error)
3434
3535 // GetBlobUpload returns a writer for the upload.
3636 GetBlobUpload(uuid string) (io.WriteCloser, error)
@@ -30,7 +30,7 @@ type BlobStorage interface {
30 StartBlobUpload(name string) (string, error)30 StartBlobUpload(name string) (string, error)
31 31
32 // AppendBlobChunk appends data to an in-progress upload.32 // AppendBlobChunk appends data to an in-progress upload.
33- AppendBlobChunk(name, uuid string, data io.Reader) (int64, error)33+ AppendBlobChunk(name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error)
34 34
35 // GetBlobUpload returns a writer for the upload.35 // GetBlobUpload returns a writer for the upload.
36 GetBlobUpload(uuid string) (io.WriteCloser, error)36 GetBlobUpload(uuid string) (io.WriteCloser, error)
M internal/domain/errors.go +1 -0
@@ -34,6 +34,7 @@ var (
3434 ErrInvalidDigest = errors.New("invalid digest")
3535 ErrDigestMismatch = errors.New("digest mismatch")
3636 ErrUnauthorized = errors.New("unauthorized")
37+ ErrBlobSizeExceeded = errors.New("blob size exceeds maximum")
3738
3839 // Network errors
3940 ErrNetworkNotFound = errors.New("network not found")
@@ -34,6 +34,7 @@ var (
34 ErrInvalidDigest = errors.New("invalid digest")34 ErrInvalidDigest = errors.New("invalid digest")
35 ErrDigestMismatch = errors.New("digest mismatch")35 ErrDigestMismatch = errors.New("digest mismatch")
36 ErrUnauthorized = errors.New("unauthorized")36 ErrUnauthorized = errors.New("unauthorized")
37+ ErrBlobSizeExceeded = errors.New("blob size exceeds maximum")
37 38
38 // Network errors39 // Network errors
39 ErrNetworkNotFound = errors.New("network not found")40 ErrNetworkNotFound = errors.New("network not found")
M internal/usecase/registry/service.go +2 -2
@@ -304,7 +304,7 @@ func (s *Service) StartUpload(ctx context.Context, name string) (string, error)
304304 }
305305
306306 // AppendBlobChunk appends data to an in-progress blob upload.
307-func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error) {
307+func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) {
308308 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
309309 zerowrap.FieldLayer: "usecase",
310310 zerowrap.FieldUseCase: "AppendBlobChunk",
@@ -313,7 +313,7 @@ func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data i
313313 })
314314 log := zerowrap.FromCtx(ctx)
315315
316- length, err := s.blobStorage.AppendBlobChunk(name, uuid, data)
316+ length, err := s.blobStorage.AppendBlobChunk(name, uuid, data, contentLength, maxBlobSize)
317317 if err != nil {
318318 return 0, log.WrapErr(err, "failed to append blob chunk")
319319 }
@@ -304,7 +304,7 @@ func (s *Service) StartUpload(ctx context.Context, name string) (string, error)
304 }304 }
305 305
306 // AppendBlobChunk appends data to an in-progress blob upload.306 // AppendBlobChunk appends data to an in-progress blob upload.
307-func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader) (int64, error) {307+func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data io.Reader, contentLength, maxBlobSize int64) (int64, error) {
308 ctx = zerowrap.CtxWithFields(ctx, map[string]any{308 ctx = zerowrap.CtxWithFields(ctx, map[string]any{
309 zerowrap.FieldLayer: "usecase",309 zerowrap.FieldLayer: "usecase",
310 zerowrap.FieldUseCase: "AppendBlobChunk",310 zerowrap.FieldUseCase: "AppendBlobChunk",
@@ -313,7 +313,7 @@ func (s *Service) AppendBlobChunk(ctx context.Context, name, uuid string, data i
313 })313 })
314 log := zerowrap.FromCtx(ctx)314 log := zerowrap.FromCtx(ctx)
315 315
316- length, err := s.blobStorage.AppendBlobChunk(name, uuid, data)316+ length, err := s.blobStorage.AppendBlobChunk(name, uuid, data, contentLength, maxBlobSize)
317 if err != nil {317 if err != nil {
318 return 0, log.WrapErr(err, "failed to append blob chunk")318 return 0, log.WrapErr(err, "failed to append blob chunk")
319 }319 }
M internal/usecase/registry/service_test.go +32 -0
@@ -306,6 +306,38 @@ func TestService_StartUpload_Error(t *testing.T) {
306306 assert.Contains(t, err.Error(), "failed to start blob upload")
307307 }
308308
309+func TestService_AppendBlobChunk_PassesMaxBlobSize(t *testing.T) {
310+ blobStorage := mocks.NewMockBlobStorage(t)
311+ manifestStorage := mocks.NewMockManifestStorage(t)
312+ eventBus := mocks.NewMockEventPublisher(t)
313+
314+ svc := NewService(blobStorage, manifestStorage, eventBus)
315+ ctx := testContext()
316+
317+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-id", mock.Anything, int64(4), int64(10)).Return(int64(4), nil)
318+
319+ length, err := svc.AppendBlobChunk(ctx, "myapp", "upload-id", strings.NewReader("data"), 4, 10)
320+
321+ assert.NoError(t, err)
322+ assert.Equal(t, int64(4), length)
323+}
324+
325+func TestService_AppendBlobChunk_MaxBlobSizeExceeded(t *testing.T) {
326+ blobStorage := mocks.NewMockBlobStorage(t)
327+ manifestStorage := mocks.NewMockManifestStorage(t)
328+ eventBus := mocks.NewMockEventPublisher(t)
329+
330+ svc := NewService(blobStorage, manifestStorage, eventBus)
331+ ctx := testContext()
332+
333+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-id", mock.Anything, int64(11), int64(10)).Return(int64(0), domain.ErrBlobSizeExceeded)
334+
335+ _, err := svc.AppendBlobChunk(ctx, "myapp", "upload-id", strings.NewReader("too large"), 11, 10)
336+
337+ require.Error(t, err)
338+ assert.ErrorIs(t, err, domain.ErrBlobSizeExceeded)
339+}
340+
309341 func TestService_FinishUpload_Success(t *testing.T) {
310342 blobStorage := mocks.NewMockBlobStorage(t)
311343 manifestStorage := mocks.NewMockManifestStorage(t)
@@ -306,6 +306,38 @@ func TestService_StartUpload_Error(t *testing.T) {
306 assert.Contains(t, err.Error(), "failed to start blob upload")306 assert.Contains(t, err.Error(), "failed to start blob upload")
307 }307 }
308 308
309+func TestService_AppendBlobChunk_PassesMaxBlobSize(t *testing.T) {
310+ blobStorage := mocks.NewMockBlobStorage(t)
311+ manifestStorage := mocks.NewMockManifestStorage(t)
312+ eventBus := mocks.NewMockEventPublisher(t)
313+
314+ svc := NewService(blobStorage, manifestStorage, eventBus)
315+ ctx := testContext()
316+
317+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-id", mock.Anything, int64(4), int64(10)).Return(int64(4), nil)
318+
319+ length, err := svc.AppendBlobChunk(ctx, "myapp", "upload-id", strings.NewReader("data"), 4, 10)
320+
321+ assert.NoError(t, err)
322+ assert.Equal(t, int64(4), length)
323+}
324+
325+func TestService_AppendBlobChunk_MaxBlobSizeExceeded(t *testing.T) {
326+ blobStorage := mocks.NewMockBlobStorage(t)
327+ manifestStorage := mocks.NewMockManifestStorage(t)
328+ eventBus := mocks.NewMockEventPublisher(t)
329+
330+ svc := NewService(blobStorage, manifestStorage, eventBus)
331+ ctx := testContext()
332+
333+ blobStorage.EXPECT().AppendBlobChunk("myapp", "upload-id", mock.Anything, int64(11), int64(10)).Return(int64(0), domain.ErrBlobSizeExceeded)
334+
335+ _, err := svc.AppendBlobChunk(ctx, "myapp", "upload-id", strings.NewReader("too large"), 11, 10)
336+
337+ require.Error(t, err)
338+ assert.ErrorIs(t, err, domain.ErrBlobSizeExceeded)
339+}
340+
309 func TestService_FinishUpload_Success(t *testing.T) {341 func TestService_FinishUpload_Success(t *testing.T) {
310 blobStorage := mocks.NewMockBlobStorage(t)342 blobStorage := mocks.NewMockBlobStorage(t)
311 manifestStorage := mocks.NewMockManifestStorage(t)343 manifestStorage := mocks.NewMockManifestStorage(t)

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 7

Tags 106

↑↓ moveenter openesc close