From e0afe52c99a867d9e3195e5ebde3b2d6914c9550 Mon Sep 17 00:00:00 2001 From: sig Date: Wed, 22 Apr 2026 22:32:12 +0100 Subject: [PATCH 1/6] test: add failing tests for SOC upload via POST /chunks SOC chunks uploaded via the generic /chunks endpoint are misclassified as CAC chunks because the handler tries CAC parsing first, which always succeeds for valid SOC data (8-4104 bytes). These tests demonstrate that SOC uploads return incorrect addresses and are unretrievable. --- pkg/api/chunk_test.go | 217 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 217 insertions(+) diff --git a/pkg/api/chunk_test.go b/pkg/api/chunk_test.go index b2512aa2ae8..5b8a678c904 100644 --- a/pkg/api/chunk_test.go +++ b/pkg/api/chunk_test.go @@ -23,9 +23,11 @@ import ( mockstorer "github.com/ethersphere/bee/v2/pkg/storer/mock" "github.com/ethersphere/bee/v2/pkg/api" + "github.com/ethersphere/bee/v2/pkg/cac" "github.com/ethersphere/bee/v2/pkg/jsonhttp" "github.com/ethersphere/bee/v2/pkg/jsonhttp/jsonhttptest" testingpostage "github.com/ethersphere/bee/v2/pkg/postage/testing" + testingsoc "github.com/ethersphere/bee/v2/pkg/soc/testing" testingc "github.com/ethersphere/bee/v2/pkg/storage/testing" "github.com/ethersphere/bee/v2/pkg/swarm" ) @@ -282,3 +284,218 @@ func TestPreSignedUpload(t *testing.T) { jsonhttptest.WithRequestBody(bytes.NewReader(chunk.Data())), ) } + +// nolint:paralleltest,tparallel +// TestChunkUploadSOC tests that SOC chunks uploaded via POST /chunks are correctly +// identified and stored at their SOC address, not misclassified as CAC chunks. +func TestChunkUploadSOC(t *testing.T) { + t.Parallel() + + var ( + chunksEndpoint = "/chunks" + chunksResource = func(a swarm.Address) string { return "/chunks/" + a.String() } + ) + + t.Run("soc upload returns soc address", func(t *testing.T) { + var ( + mockSOC = testingsoc.GenerateMockSOC(t, []byte("test payload")) + socChunk = mockSOC.Chunk() + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + // drain pusher feed + go func() { <-storerMock.PusherFeed() }() + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(socChunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: mockSOC.Address()}), + ) + }) + + t.Run("soc upload chunk is retrievable", func(t *testing.T) { + var ( + mockSOC = testingsoc.GenerateMockSOC(t, []byte("retrievable")) + socChunk = mockSOC.Chunk() + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + // drain pusher feed + go func() { <-storerMock.PusherFeed() }() + + // upload + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(socChunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: mockSOC.Address()}), + ) + + // retrieve by SOC address + jsonhttptest.Request(t, client, http.MethodGet, chunksResource(mockSOC.Address()), http.StatusOK, + jsonhttptest.WithExpectedResponse(socChunk.Data()), + jsonhttptest.WithExpectedContentLength(len(socChunk.Data())), + ) + }) + + t.Run("soc direct upload", func(t *testing.T) { + var ( + mockSOC = testingsoc.GenerateMockSOC(t, []byte("direct")) + socChunk = mockSOC.Chunk() + storerMock = mockstorer.New() + client, _, _, chanStorer = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + DirectUpload: true, + }) + ) + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(socChunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: mockSOC.Address()}), + ) + + time.Sleep(time.Millisecond * 100) + err := spinlock.Wait(time.Second, func() bool { return chanStorer.Has(mockSOC.Address()) }) + if err != nil { + t.Fatal("soc chunk not found at soc address in direct upload channel") + } + }) + + t.Run("cac upload still works", func(t *testing.T) { + var ( + chunk = testingc.GenerateTestRandomChunk() + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + go func() { <-storerMock.PusherFeed() }() + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(chunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: chunk.Address()}), + ) + }) + + t.Run("cac upload small payload", func(t *testing.T) { + // Minimal CAC: span (8 bytes) + 1 byte payload = 9 bytes total + var ( + cacChunk, _ = cac.New([]byte{0x01}) + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + go func() { <-storerMock.PusherFeed() }() + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(cacChunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: cacChunk.Address()}), + ) + }) + + t.Run("cac upload max payload", func(t *testing.T) { + // Maximum CAC: span (8 bytes) + 4096 byte payload = 4104 bytes total + var ( + payload = make([]byte, swarm.ChunkSize) + cacChunk, _ = cac.New(payload) + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + go func() { <-storerMock.PusherFeed() }() + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(cacChunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: cacChunk.Address()}), + ) + }) + + t.Run("data too short", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader([]byte{0x01, 0x02, 0x03})), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "insufficient data length", + Code: http.StatusBadRequest, + }), + ) + }) + + t.Run("invalid soc falls back to cac", func(t *testing.T) { + // Data that is >= SocMinChunkSize (105 bytes) but not a valid SOC + // (random data won't have valid ECDSA signature). Should fall back to CAC. + var ( + payload = make([]byte, swarm.SocMinChunkSize-swarm.SpanSize) // 97 bytes payload + cacChunk, _ = cac.New(payload) // 105 bytes total (span + 97) + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + go func() { <-storerMock.PusherFeed() }() + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestBody(bytes.NewReader(cacChunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: cacChunk.Address()}), + ) + }) + + t.Run("soc with pre-signed stamp", func(t *testing.T) { + var ( + mockSOC = testingsoc.GenerateMockSOC(t, []byte("stamped")) + socChunk = mockSOC.Chunk() + storerMock = mockstorer.New() + batchStore = mockbatchstore.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + BatchStore: batchStore, + }) + ) + + key, _ := crypto.GenerateSecp256k1Key() + signer := crypto.NewDefaultSigner(key) + owner, _ := signer.EthereumAddress() + stamp := testingpostage.MustNewValidStamp(signer, mockSOC.Address()) + _ = batchStore.Save(&postage.Batch{ + ID: stamp.BatchID(), + Owner: owner.Bytes(), + }) + stampBytes, _ := stamp.MarshalBinary() + + go func() { <-storerMock.PusherFeed() }() + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageStampHeader, hex.EncodeToString(stampBytes)), + jsonhttptest.WithRequestBody(bytes.NewReader(socChunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: mockSOC.Address()}), + ) + }) +} From a0b73a1ef49b02826a8faca06c2a38cd85803ac7 Mon Sep 17 00:00:00 2001 From: sig Date: Wed, 22 Apr 2026 22:35:08 +0100 Subject: [PATCH 2/6] fix: reverse SOC/CAC detection order in POST /chunks handler Try SOC parsing before CAC in the chunk upload handler. CAC parsing accepts any data between 8-4104 bytes, so valid SOC data was always misclassified as CAC and stored at the wrong content-addressed hash instead of the correct SOC address (Hash(Identifier || Owner)). --- pkg/api/chunk.go | 34 +++++++++++++++++++--------------- pkg/api/chunk_test.go | 18 +++++++++++++++--- 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/pkg/api/chunk.go b/pkg/api/chunk.go index 6ddb2a50b10..bb9259e74df 100644 --- a/pkg/api/chunk.go +++ b/pkg/api/chunk.go @@ -138,19 +138,11 @@ func (s *Service) chunkUploadHandler(w http.ResponseWriter, r *http.Request) { return } - chunk, err := cac.NewWithDataSpan(data) - if err != nil { - // not a valid cac chunk. Check if it's a replica soc chunk. - logger.Debug("chunk upload: create chunk failed", "error", err) - - // FromChunk only uses the chunk data to recreate the soc chunk. So the address is irrelevant. - sch, err := soc.FromChunk(swarm.NewChunk(swarm.EmptyAddress, data)) - if err != nil { - logger.Debug("chunk upload: create soc chunk from data failed", "error", err) - logger.Error(nil, "chunk upload: create chunk error") - jsonhttp.InternalServerError(ow, "create chunk error") - return - } + // Try SOC first — CAC parsing is too permissive (accepts any 8-4104 byte data), + // so valid SOCs would always be misclassified as CACs if we tried CAC first. + var chunk swarm.Chunk + sch, err := soc.FromChunk(swarm.NewChunk(swarm.EmptyAddress, data)) + if err == nil { chunk, err = sch.Chunk() if err != nil { logger.Debug("chunk upload: create chunk from soc failed", "error", err) @@ -158,9 +150,21 @@ func (s *Service) chunkUploadHandler(w http.ResponseWriter, r *http.Request) { jsonhttp.InternalServerError(ow, "create chunk error") return } - if !soc.Valid(chunk) { - logger.Debug("chunk upload: invalid soc chunk") + // Parsed as SOC structure but invalid — fall through to CAC + chunk, err = cac.NewWithDataSpan(data) + if err != nil { + logger.Debug("chunk upload: create chunk failed", "error", err) + logger.Error(nil, "chunk upload: create chunk error") + jsonhttp.InternalServerError(ow, "create chunk error") + return + } + } + } else { + // Not a SOC — try CAC + chunk, err = cac.NewWithDataSpan(data) + if err != nil { + logger.Debug("chunk upload: create chunk failed", "error", err) logger.Error(nil, "chunk upload: create chunk error") jsonhttp.InternalServerError(ow, "create chunk error") return diff --git a/pkg/api/chunk_test.go b/pkg/api/chunk_test.go index 5b8a678c904..3142825258c 100644 --- a/pkg/api/chunk_test.go +++ b/pkg/api/chunk_test.go @@ -328,16 +328,28 @@ func TestChunkUploadSOC(t *testing.T) { }) ) - // drain pusher feed - go func() { <-storerMock.PusherFeed() }() + tag, err := storerMock.NewSession() + if err != nil { + t.Fatal(err) + } - // upload + // upload with tag so chunk is stored in ChunkStore (deferred upload path) jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmTagHeader, fmt.Sprintf("%d", tag.TagID)), jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), jsonhttptest.WithRequestBody(bytes.NewReader(socChunk.Data())), jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: mockSOC.Address()}), ) + // verify chunk exists in store at SOC address + has, err := storerMock.ChunkStore().Has(context.Background(), mockSOC.Address()) + if err != nil { + t.Fatal(err) + } + if !has { + t.Fatal("soc chunk not found in store at soc address") + } + // retrieve by SOC address jsonhttptest.Request(t, client, http.MethodGet, chunksResource(mockSOC.Address()), http.StatusOK, jsonhttptest.WithExpectedResponse(socChunk.Data()), From 489b34f71475f4fe54272c0451d45e8338593653 Mon Sep 17 00:00:00 2001 From: sig Date: Mon, 27 Apr 2026 21:04:37 +0100 Subject: [PATCH 3/6] chore: fix gofmt alignment in chunk_test.go --- pkg/api/chunk_test.go | 46 +++++++++++++++++++++---------------------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/pkg/api/chunk_test.go b/pkg/api/chunk_test.go index 3142825258c..25b69639eb1 100644 --- a/pkg/api/chunk_test.go +++ b/pkg/api/chunk_test.go @@ -298,9 +298,9 @@ func TestChunkUploadSOC(t *testing.T) { t.Run("soc upload returns soc address", func(t *testing.T) { var ( - mockSOC = testingsoc.GenerateMockSOC(t, []byte("test payload")) - socChunk = mockSOC.Chunk() - storerMock = mockstorer.New() + mockSOC = testingsoc.GenerateMockSOC(t, []byte("test payload")) + socChunk = mockSOC.Chunk() + storerMock = mockstorer.New() client, _, _, _ = newTestServer(t, testServerOptions{ Storer: storerMock, Post: mockpost.New(mockpost.WithAcceptAll()), @@ -319,9 +319,9 @@ func TestChunkUploadSOC(t *testing.T) { t.Run("soc upload chunk is retrievable", func(t *testing.T) { var ( - mockSOC = testingsoc.GenerateMockSOC(t, []byte("retrievable")) - socChunk = mockSOC.Chunk() - storerMock = mockstorer.New() + mockSOC = testingsoc.GenerateMockSOC(t, []byte("retrievable")) + socChunk = mockSOC.Chunk() + storerMock = mockstorer.New() client, _, _, _ = newTestServer(t, testServerOptions{ Storer: storerMock, Post: mockpost.New(mockpost.WithAcceptAll()), @@ -359,9 +359,9 @@ func TestChunkUploadSOC(t *testing.T) { t.Run("soc direct upload", func(t *testing.T) { var ( - mockSOC = testingsoc.GenerateMockSOC(t, []byte("direct")) - socChunk = mockSOC.Chunk() - storerMock = mockstorer.New() + mockSOC = testingsoc.GenerateMockSOC(t, []byte("direct")) + socChunk = mockSOC.Chunk() + storerMock = mockstorer.New() client, _, _, chanStorer = newTestServer(t, testServerOptions{ Storer: storerMock, Post: mockpost.New(mockpost.WithAcceptAll()), @@ -384,8 +384,8 @@ func TestChunkUploadSOC(t *testing.T) { t.Run("cac upload still works", func(t *testing.T) { var ( - chunk = testingc.GenerateTestRandomChunk() - storerMock = mockstorer.New() + chunk = testingc.GenerateTestRandomChunk() + storerMock = mockstorer.New() client, _, _, _ = newTestServer(t, testServerOptions{ Storer: storerMock, Post: mockpost.New(mockpost.WithAcceptAll()), @@ -404,8 +404,8 @@ func TestChunkUploadSOC(t *testing.T) { t.Run("cac upload small payload", func(t *testing.T) { // Minimal CAC: span (8 bytes) + 1 byte payload = 9 bytes total var ( - cacChunk, _ = cac.New([]byte{0x01}) - storerMock = mockstorer.New() + cacChunk, _ = cac.New([]byte{0x01}) + storerMock = mockstorer.New() client, _, _, _ = newTestServer(t, testServerOptions{ Storer: storerMock, Post: mockpost.New(mockpost.WithAcceptAll()), @@ -424,9 +424,9 @@ func TestChunkUploadSOC(t *testing.T) { t.Run("cac upload max payload", func(t *testing.T) { // Maximum CAC: span (8 bytes) + 4096 byte payload = 4104 bytes total var ( - payload = make([]byte, swarm.ChunkSize) - cacChunk, _ = cac.New(payload) - storerMock = mockstorer.New() + payload = make([]byte, swarm.ChunkSize) + cacChunk, _ = cac.New(payload) + storerMock = mockstorer.New() client, _, _, _ = newTestServer(t, testServerOptions{ Storer: storerMock, Post: mockpost.New(mockpost.WithAcceptAll()), @@ -462,9 +462,9 @@ func TestChunkUploadSOC(t *testing.T) { // Data that is >= SocMinChunkSize (105 bytes) but not a valid SOC // (random data won't have valid ECDSA signature). Should fall back to CAC. var ( - payload = make([]byte, swarm.SocMinChunkSize-swarm.SpanSize) // 97 bytes payload - cacChunk, _ = cac.New(payload) // 105 bytes total (span + 97) - storerMock = mockstorer.New() + payload = make([]byte, swarm.SocMinChunkSize-swarm.SpanSize) // 97 bytes payload + cacChunk, _ = cac.New(payload) // 105 bytes total (span + 97) + storerMock = mockstorer.New() client, _, _, _ = newTestServer(t, testServerOptions{ Storer: storerMock, Post: mockpost.New(mockpost.WithAcceptAll()), @@ -482,10 +482,10 @@ func TestChunkUploadSOC(t *testing.T) { t.Run("soc with pre-signed stamp", func(t *testing.T) { var ( - mockSOC = testingsoc.GenerateMockSOC(t, []byte("stamped")) - socChunk = mockSOC.Chunk() - storerMock = mockstorer.New() - batchStore = mockbatchstore.New() + mockSOC = testingsoc.GenerateMockSOC(t, []byte("stamped")) + socChunk = mockSOC.Chunk() + storerMock = mockstorer.New() + batchStore = mockbatchstore.New() client, _, _, _ = newTestServer(t, testServerOptions{ Storer: storerMock, BatchStore: batchStore, From ebcb09179ddfb74bdc8d82dddcab537b3e13d074 Mon Sep 17 00:00:00 2001 From: sig Date: Wed, 29 Apr 2026 13:35:31 +0100 Subject: [PATCH 4/6] feat: add Swarm-Chunk-Type header to POST /chunks endpoint Allow callers to explicitly specify chunk type ('soc'/'1' or 'cac'/'0') via the Swarm-Chunk-Type header, skipping auto-detection and avoiding unnecessary SOC signature validation for CAC uploads. --- pkg/api/api.go | 1 + pkg/api/chunk.go | 67 +++++++++++++++++------ pkg/api/chunk_test.go | 124 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 174 insertions(+), 18 deletions(-) diff --git a/pkg/api/api.go b/pkg/api/api.go index 3f17a83b100..ac403fd4c11 100644 --- a/pkg/api/api.go +++ b/pkg/api/api.go @@ -94,6 +94,7 @@ const ( SwarmActTimestampHeader = "Swarm-Act-Timestamp" SwarmActPublisherHeader = "Swarm-Act-Publisher" SwarmActHistoryAddressHeader = "Swarm-Act-History-Address" + SwarmChunkTypeHeader = "Swarm-Chunk-Type" ImmutableHeader = "Immutable" GasPriceHeader = "Gas-Price" diff --git a/pkg/api/chunk.go b/pkg/api/chunk.go index bb9259e74df..39558183fdf 100644 --- a/pkg/api/chunk.go +++ b/pkg/api/chunk.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "strconv" + "strings" "github.com/ethersphere/bee/v2/pkg/accesscontrol" "github.com/ethersphere/bee/v2/pkg/cac" @@ -37,6 +38,7 @@ func (s *Service) chunkUploadHandler(w http.ResponseWriter, r *http.Request) { SwarmTag uint64 `map:"Swarm-Tag"` Act bool `map:"Swarm-Act"` HistoryAddress swarm.Address `map:"Swarm-Act-History-Address"` + ChunkType string `map:"Swarm-Chunk-Type"` }{} if response := s.mapStructure(r.Header, &headers); response != nil { response("invalid header params", logger, w) @@ -138,20 +140,53 @@ func (s *Service) chunkUploadHandler(w http.ResponseWriter, r *http.Request) { return } - // Try SOC first — CAC parsing is too permissive (accepts any 8-4104 byte data), - // so valid SOCs would always be misclassified as CACs if we tried CAC first. var chunk swarm.Chunk - sch, err := soc.FromChunk(swarm.NewChunk(swarm.EmptyAddress, data)) - if err == nil { + switch strings.ToLower(headers.ChunkType) { + case "soc", "1": + sch, err := soc.FromChunk(swarm.NewChunk(swarm.EmptyAddress, data)) + if err != nil { + logger.Debug("chunk upload: soc parse failed", "error", err) + logger.Error(nil, "chunk upload: invalid soc chunk") + jsonhttp.BadRequest(ow, "invalid soc chunk") + return + } chunk, err = sch.Chunk() + if err != nil || !soc.Valid(chunk) { + logger.Debug("chunk upload: soc validation failed", "error", err) + logger.Error(nil, "chunk upload: invalid soc chunk") + jsonhttp.BadRequest(ow, "invalid soc chunk") + return + } + case "cac", "0": + chunk, err = cac.NewWithDataSpan(data) if err != nil { - logger.Debug("chunk upload: create chunk from soc failed", "error", err) - logger.Error(nil, "chunk upload: create chunk error") - jsonhttp.InternalServerError(ow, "create chunk error") + logger.Debug("chunk upload: cac parse failed", "error", err) + logger.Error(nil, "chunk upload: invalid cac chunk") + jsonhttp.BadRequest(ow, "invalid cac chunk") return } - if !soc.Valid(chunk) { - // Parsed as SOC structure but invalid — fall through to CAC + case "": + // No chunk type specified — auto-detect. + // Try SOC first because CAC is too permissive (accepts any 8-4104 byte data). + sch, err := soc.FromChunk(swarm.NewChunk(swarm.EmptyAddress, data)) + if err == nil { + chunk, err = sch.Chunk() + if err != nil { + logger.Debug("chunk upload: create chunk from soc failed", "error", err) + logger.Error(nil, "chunk upload: create chunk error") + jsonhttp.InternalServerError(ow, "create chunk error") + return + } + if !soc.Valid(chunk) { + chunk, err = cac.NewWithDataSpan(data) + if err != nil { + logger.Debug("chunk upload: create chunk failed", "error", err) + logger.Error(nil, "chunk upload: create chunk error") + jsonhttp.InternalServerError(ow, "create chunk error") + return + } + } + } else { chunk, err = cac.NewWithDataSpan(data) if err != nil { logger.Debug("chunk upload: create chunk failed", "error", err) @@ -160,15 +195,11 @@ func (s *Service) chunkUploadHandler(w http.ResponseWriter, r *http.Request) { return } } - } else { - // Not a SOC — try CAC - chunk, err = cac.NewWithDataSpan(data) - if err != nil { - logger.Debug("chunk upload: create chunk failed", "error", err) - logger.Error(nil, "chunk upload: create chunk error") - jsonhttp.InternalServerError(ow, "create chunk error") - return - } + default: + logger.Debug("chunk upload: invalid chunk type", "type", headers.ChunkType) + logger.Error(nil, "chunk upload: invalid chunk type") + jsonhttp.BadRequest(ow, "invalid chunk type; expected 'soc' or 'cac'") + return } err = putter.Put(r.Context(), chunk) diff --git a/pkg/api/chunk_test.go b/pkg/api/chunk_test.go index 25b69639eb1..fc929972159 100644 --- a/pkg/api/chunk_test.go +++ b/pkg/api/chunk_test.go @@ -480,6 +480,130 @@ func TestChunkUploadSOC(t *testing.T) { ) }) + t.Run("soc upload with chunk type header", func(t *testing.T) { + var ( + mockSOC = testingsoc.GenerateMockSOC(t, []byte("header soc")) + socChunk = mockSOC.Chunk() + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + go func() { <-storerMock.PusherFeed() }() + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmChunkTypeHeader, "soc"), + jsonhttptest.WithRequestBody(bytes.NewReader(socChunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: mockSOC.Address()}), + ) + }) + + t.Run("soc upload with chunk type header numeric", func(t *testing.T) { + var ( + mockSOC = testingsoc.GenerateMockSOC(t, []byte("header soc 1")) + socChunk = mockSOC.Chunk() + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + go func() { <-storerMock.PusherFeed() }() + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmChunkTypeHeader, "1"), + jsonhttptest.WithRequestBody(bytes.NewReader(socChunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: mockSOC.Address()}), + ) + }) + + t.Run("cac upload with chunk type header", func(t *testing.T) { + var ( + chunk = testingc.GenerateTestRandomChunk() + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + go func() { <-storerMock.PusherFeed() }() + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmChunkTypeHeader, "cac"), + jsonhttptest.WithRequestBody(bytes.NewReader(chunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: chunk.Address()}), + ) + }) + + t.Run("cac upload with chunk type header numeric", func(t *testing.T) { + var ( + chunk = testingc.GenerateTestRandomChunk() + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + go func() { <-storerMock.PusherFeed() }() + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmChunkTypeHeader, "0"), + jsonhttptest.WithRequestBody(bytes.NewReader(chunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: chunk.Address()}), + ) + }) + + t.Run("soc data with cac header returns cac address", func(t *testing.T) { + // When SOC data is uploaded with Swarm-Chunk-Type: cac, it should be + // treated as CAC (no SOC detection), producing the content address. + var ( + mockSOC = testingsoc.GenerateMockSOC(t, []byte("wrong type")) + socChunk = mockSOC.Chunk() + cacChunk, _ = cac.NewWithDataSpan(socChunk.Data()) + storerMock = mockstorer.New() + client, _, _, _ = newTestServer(t, testServerOptions{ + Storer: storerMock, + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + ) + + go func() { <-storerMock.PusherFeed() }() + + // Should succeed but return the CAC address, not the SOC address + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusCreated, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmChunkTypeHeader, "cac"), + jsonhttptest.WithRequestBody(bytes.NewReader(socChunk.Data())), + jsonhttptest.WithExpectedJSONResponse(api.ChunkAddressResponse{Reference: cacChunk.Address()}), + ) + }) + + t.Run("invalid chunk type header", func(t *testing.T) { + client, _, _, _ := newTestServer(t, testServerOptions{ + Storer: mockstorer.New(), + Post: mockpost.New(mockpost.WithAcceptAll()), + }) + + jsonhttptest.Request(t, client, http.MethodPost, chunksEndpoint, http.StatusBadRequest, + jsonhttptest.WithRequestHeader(api.SwarmPostageBatchIdHeader, batchOkStr), + jsonhttptest.WithRequestHeader(api.SwarmChunkTypeHeader, "invalid"), + jsonhttptest.WithRequestBody(bytes.NewReader([]byte{0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0xff})), + jsonhttptest.WithExpectedJSONResponse(jsonhttp.StatusResponse{ + Message: "invalid chunk type; expected 'soc' or 'cac'", + Code: http.StatusBadRequest, + }), + ) + }) + t.Run("soc with pre-signed stamp", func(t *testing.T) { var ( mockSOC = testingsoc.GenerateMockSOC(t, []byte("stamped")) From 77ec3750cbbfce96a1526b726465afc8156e8d49 Mon Sep 17 00:00:00 2001 From: sig Date: Thu, 30 Apr 2026 10:54:44 +0100 Subject: [PATCH 5/6] ci: retrigger checks From af3709a229474a50cedcf35e6c494c161492d486 Mon Sep 17 00:00:00 2001 From: sig Date: Thu, 30 Apr 2026 14:46:24 +0100 Subject: [PATCH 6/6] ci: retrigger checks