Skip to content

Commit 908f36e

Browse files
committed
feat(lib/store): verify download blob digest when moving to cache
1 parent f7175d0 commit 908f36e

6 files changed

Lines changed: 204 additions & 43 deletions

File tree

lib/store/ca_download_store.go

Lines changed: 26 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,17 +21,17 @@ import (
2121
"github.com/uber-go/tally"
2222
"github.com/uber/kraken/lib/store/base"
2323
"github.com/uber/kraken/lib/store/metadata"
24+
"github.com/uber/kraken/utils/closers"
2425
)
2526

2627
// CADownloadStore allows simultaneously downloading and uploading
2728
// content-adddressable files.
2829
type CADownloadStore struct {
30+
config CADownloadStoreConfig
2931
backend base.FileStore
3032
downloadState base.FileState
3133
cacheState base.FileState
3234
cleanup *cleanupManager
33-
readPartSize int
34-
writePartSize int
3535
}
3636

3737
// NewCADownloadStore creates a new CADownloadStore.
@@ -61,12 +61,11 @@ func NewCADownloadStore(config CADownloadStoreConfig, stats tally.Scope) (*CADow
6161
backend.NewFileOp().AcceptState(cacheState))
6262

6363
return &CADownloadStore{
64+
config: config,
6465
backend: backend,
6566
downloadState: downloadState,
6667
cacheState: cacheState,
6768
cleanup: cleanup,
68-
readPartSize: config.ReadPartSize,
69-
writePartSize: config.WritePartSize,
7069
}, nil
7170
}
7271

@@ -80,13 +79,33 @@ func (s *CADownloadStore) CreateDownloadFile(name string, length int64) error {
8079
return s.backend.NewFileOp().CreateFile(name, s.downloadState, length)
8180
}
8281

82+
// GetDownloadFileReader returns a FileReader for name.
83+
func (s *CADownloadStore) getDownloadFileReader(name string) (FileReader, error) {
84+
return s.backend.NewFileOp().AcceptState(s.downloadState).GetFileReader(name, s.config.ReadPartSize)
85+
}
86+
8387
// GetDownloadFileReadWriter returns a FileReadWriter for name.
8488
func (s *CADownloadStore) GetDownloadFileReadWriter(name string) (FileReadWriter, error) {
85-
return s.backend.NewFileOp().AcceptState(s.downloadState).GetFileReadWriter(name, s.readPartSize, s.writePartSize)
89+
return s.backend.NewFileOp().AcceptState(s.downloadState).GetFileReadWriter(name, s.config.ReadPartSize, s.config.WritePartSize)
8690
}
8791

88-
// MoveDownloadFileToCache moves a download file to the cache.
92+
// MoveDownloadFileToCache moves a download file to the cache
93+
// after verifying its digest.
8994
func (s *CADownloadStore) MoveDownloadFileToCache(name string) error {
95+
f, err := s.getDownloadFileReader(name)
96+
if err != nil {
97+
if s.InCacheError(err) {
98+
// Inform the caller the file has already been moved,
99+
// matching the behaviour of MoveFile(...).
100+
return os.ErrExist
101+
}
102+
return fmt.Errorf("get file reader %s: %w", name, err)
103+
}
104+
defer closers.Close(f)
105+
106+
if err := verifyDigest(f, name, s.config.SkipHashVerification); err != nil {
107+
return fmt.Errorf("verify digest: %w", err)
108+
}
90109
return s.backend.NewFileOp().AcceptState(s.downloadState).MoveFile(name, s.cacheState)
91110
}
92111

@@ -158,7 +177,7 @@ func (s *CADownloadStore) Any() *CADownloadStoreScope {
158177

159178
// GetFileReader returns a reader for name.
160179
func (a *CADownloadStoreScope) GetFileReader(name string) (FileReader, error) {
161-
return a.op.GetFileReader(name, a.store.readPartSize)
180+
return a.op.GetFileReader(name, a.store.config.ReadPartSize)
162181
}
163182

164183
// GetFileStat returns file info for name.

lib/store/ca_download_store_test.go

Lines changed: 135 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,10 @@
1414
package store
1515

1616
import (
17+
"errors"
1718
"os"
1819
"sync"
20+
"sync/atomic"
1921
"testing"
2022

2123
"github.com/uber/kraken/core"
@@ -31,13 +33,21 @@ func TestCADownloadStoreDownloadAndDeleteFiles(t *testing.T) {
3133

3234
var names []string
3335
var wg sync.WaitGroup
34-
for i := 0; i < 100; i++ {
35-
name := core.DigestFixture().Hex()
36+
for range 100 {
37+
blob := core.NewBlobFixture()
38+
name := blob.Digest.Hex()
3639
names = append(names, name)
3740
wg.Add(1)
3841
go func() {
3942
defer wg.Done()
40-
require.NoError(s.CreateDownloadFile(name, 1))
43+
44+
require.NoError(s.CreateDownloadFile(name, blob.Length()))
45+
w, err := s.GetDownloadFileReadWriter(name)
46+
require.NoError(err)
47+
_, err = w.Write(blob.Content)
48+
require.NoError(err)
49+
require.NoError(w.Close())
50+
4151
require.NoError(s.MoveDownloadFileToCache(name))
4252
require.NoError(s.Cache().DeleteFile(name))
4353
}()
@@ -49,3 +59,125 @@ func TestCADownloadStoreDownloadAndDeleteFiles(t *testing.T) {
4959
require.True(os.IsNotExist(err))
5060
}
5161
}
62+
63+
func TestCADownloadStoreMoveDownloadFileToCacheConcurrent(t *testing.T) {
64+
require := require.New(t)
65+
66+
s, cleanup := CADownloadStoreFixture()
67+
defer cleanup()
68+
69+
blob := core.NewBlobFixture()
70+
name := blob.Digest.Hex()
71+
72+
require.NoError(s.CreateDownloadFile(name, blob.Length()))
73+
w, err := s.GetDownloadFileReadWriter(name)
74+
require.NoError(err)
75+
_, err = w.Write(blob.Content)
76+
require.NoError(err)
77+
require.NoError(w.Close())
78+
79+
var success atomic.Int32
80+
var exists atomic.Int32
81+
var errMu sync.Mutex
82+
var errs error
83+
var wg sync.WaitGroup
84+
for range 10 {
85+
wg.Add(1)
86+
go func() {
87+
defer wg.Done()
88+
89+
err := s.MoveDownloadFileToCache(name)
90+
switch err {
91+
case nil:
92+
success.Add(1)
93+
case os.ErrExist:
94+
exists.Add(1)
95+
default:
96+
errMu.Lock()
97+
errs = errors.Join(errs, err)
98+
errMu.Unlock()
99+
}
100+
}()
101+
}
102+
wg.Wait()
103+
104+
require.NoError(errs)
105+
require.Equal(int32(1), success.Load())
106+
require.Equal(int32(9), exists.Load())
107+
}
108+
109+
func TestCADownloadStoreMoveDownloadFileToCacheNonMatchingDigest(t *testing.T) {
110+
require := require.New(t)
111+
112+
s, cleanup := CADownloadStoreFixture()
113+
defer cleanup()
114+
115+
blob := core.NewBlobFixture()
116+
name := blob.Digest.Hex()
117+
118+
require.NoError(s.CreateDownloadFile(name, blob.Length()))
119+
w, err := s.GetDownloadFileReadWriter(name)
120+
require.NoError(err)
121+
corrupted := make([]byte, len(blob.Content))
122+
copy(corrupted, blob.Content)
123+
corrupted[0]++
124+
_, err = w.Write(corrupted)
125+
require.NoError(err)
126+
require.NoError(w.Close())
127+
128+
err = s.MoveDownloadFileToCache(name)
129+
require.ErrorContains(err, "verify digest: computed digest")
130+
131+
_, err = s.Download().GetFileStat(name)
132+
require.NoError(err)
133+
_, err = s.Cache().GetFileStat(name)
134+
require.True(s.InDownloadError(err))
135+
}
136+
137+
func TestCADownloadStoreMoveDownloadFileToCacheSkipVerification(t *testing.T) {
138+
require := require.New(t)
139+
140+
s, cleanup := CADownloadStoreFixture()
141+
s.config.SkipHashVerification = true
142+
defer cleanup()
143+
144+
blob := core.NewBlobFixture()
145+
name := blob.Digest.Hex()
146+
147+
require.NoError(s.CreateDownloadFile(name, blob.Length()))
148+
w, err := s.GetDownloadFileReadWriter(name)
149+
require.NoError(err)
150+
emptyBlob := make([]byte, len(blob.Content))
151+
_, err = w.Write(emptyBlob)
152+
require.NoError(err)
153+
require.NoError(w.Close())
154+
155+
err = s.MoveDownloadFileToCache(name)
156+
require.NoError(err)
157+
158+
_, err = s.Cache().GetFileStat(name)
159+
require.NoError(err)
160+
}
161+
162+
func TestCADownloadStoreMoveDownloadFileToCacheAlreadyInCache(t *testing.T) {
163+
require := require.New(t)
164+
165+
s, cleanup := CADownloadStoreFixture()
166+
defer cleanup()
167+
168+
blob := core.NewBlobFixture()
169+
name := blob.Digest.Hex()
170+
171+
require.NoError(s.CreateDownloadFile(name, blob.Length()))
172+
w, err := s.GetDownloadFileReadWriter(name)
173+
require.NoError(err)
174+
_, err = w.Write(blob.Content)
175+
require.NoError(err)
176+
require.NoError(w.Close())
177+
178+
err = s.MoveDownloadFileToCache(name)
179+
require.NoError(err)
180+
181+
err = s.MoveDownloadFileToCache(name)
182+
require.True(os.IsExist(err))
183+
}

lib/store/ca_store.go

Lines changed: 2 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -180,8 +180,8 @@ func (s *CAStore) MoveUploadFileToCache(uploadName, cacheName string) error {
180180
return fmt.Errorf("get file reader %s: %s", uploadName, err)
181181
}
182182
defer closers.Close(f)
183-
if err := s.verify(f, cacheName); err != nil {
184-
return fmt.Errorf("verify digest: %s", err)
183+
if err := verifyDigest(f, cacheName, s.config.SkipHashVerification); err != nil {
184+
return fmt.Errorf("verify digest: %w", err)
185185
}
186186

187187
return s.cacheStore.newFileOp().MoveFileFrom(cacheName, s.cacheStore.state, uploadPath)
@@ -330,28 +330,6 @@ func (s *CAStore) addItemForDiskSync(item *drainItem) {
330330
s.drain.queue.PushBack(item)
331331
}
332332

333-
// verify verifies that name is a valid SHA256 digest, and checks if the given
334-
// blob content matches the digset unless explicitly skipped.
335-
func (s *CAStore) verify(r io.Reader, name string) error {
336-
// Verify that expected name is a valid SHA256 digest.
337-
expected, err := core.NewSHA256DigestFromHex(name)
338-
if err != nil {
339-
return fmt.Errorf("new digest from file name: %s", err)
340-
}
341-
342-
if !s.config.SkipHashVerification {
343-
digester := core.NewDigester()
344-
computed, err := digester.FromReader(r)
345-
if err != nil {
346-
return fmt.Errorf("calculate digest: %s", err)
347-
}
348-
if computed != expected {
349-
return fmt.Errorf("computed digest %s doesn't match expected value %s", computed, expected)
350-
}
351-
}
352-
return nil
353-
}
354-
355333
func (s *CAStore) memoryCacheCleanupWorker() {
356334
defer s.ttlWg.Done()
357335

lib/store/config.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,4 +95,6 @@ type CADownloadStoreConfig struct {
9595
ReadPartSize int `yaml:"read_part_size"`
9696
// Part size limit for each file write. 0 means no limit.
9797
WritePartSize int `yaml:"write_part_size"`
98+
99+
SkipHashVerification bool `yaml:"skip_hash_verification"`
98100
}

lib/store/utils.go

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,12 @@ package store
1515

1616
import (
1717
"bytes"
18+
"fmt"
19+
"io"
1820
"os"
21+
22+
"github.com/uber/kraken/core"
23+
"github.com/uber/kraken/utils/log"
1924
)
2025

2126
func createOrUpdateSymlink(sourcePath, targetPath string) error {
@@ -42,6 +47,25 @@ func createOrUpdateSymlink(sourcePath, targetPath string) error {
4247
return nil
4348
}
4449

50+
func verifyDigest(r io.Reader, name string, skipHashVerification bool) error {
51+
expected, err := core.NewSHA256DigestFromHex(name)
52+
if err != nil {
53+
return fmt.Errorf("new digest from file name: %w", err)
54+
}
55+
56+
if !skipHashVerification {
57+
computed, err := core.NewDigester().FromReader(r)
58+
if err != nil {
59+
return fmt.Errorf("calculate digest: %w", err)
60+
}
61+
if computed != expected {
62+
log.With("name", name, "expected", expected, "computed", computed).Error("Digest verification did not match")
63+
return fmt.Errorf("computed digest %s doesn't match expected value %s", computed, expected)
64+
}
65+
}
66+
return nil
67+
}
68+
4569
type bufferFileReader struct {
4670
*bytes.Reader
4771
}

lib/torrent/storage/agentstorage/torrent_test.go

Lines changed: 15 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,10 @@ func (w *coordinatedWriter) Write(b []byte) (int, error) {
258258
func TestTorrentWritePieceConflictsDoNotBlock(t *testing.T) {
259259
require := require.New(t)
260260

261-
blob := core.SizedBlobFixture(1, 1)
261+
// Use two pieces to prevent triggering a blob cache move after piece
262+
// completion, which would fail as a mocked store writer is used.
263+
blob := core.SizedBlobFixture(2, 1)
264+
piece := blob.Content[:1]
262265

263266
f, cleanup := store.NewMockFileReadWriter([]byte{})
264267
defer cleanup()
@@ -278,18 +281,18 @@ func TestTorrentWritePieceConflictsDoNotBlock(t *testing.T) {
278281
done := make(chan struct{})
279282
go func() {
280283
defer close(done)
281-
require.NoError(tor.WritePiece(piecereader.NewBuffer(blob.Content), 0))
284+
require.NoError(tor.WritePiece(piecereader.NewBuffer(piece), 0))
282285
}()
283286

284287
// Writing while another goroutine is mid-write should not block.
285288
<-w.startWriting
286-
require.Equal(errWritePieceConflict, tor.WritePiece(piecereader.NewBuffer(blob.Content), 0))
289+
require.Equal(errWritePieceConflict, tor.WritePiece(piecereader.NewBuffer(piece), 0))
287290
w.stopWriting <- true
288291

289292
<-done
290293

291294
// Duplicate write should detect piece is complete.
292-
require.Equal(storage.ErrPieceComplete, tor.WritePiece(piecereader.NewBuffer(blob.Content), 0))
295+
require.Equal(storage.ErrPieceComplete, tor.WritePiece(piecereader.NewBuffer(piece), 0))
293296
}
294297

295298
func TestTorrentWritePieceFailuresRemoveDirtyStatus(t *testing.T) {
@@ -303,7 +306,10 @@ func TestTorrentWritePieceFailuresRemoveDirtyStatus(t *testing.T) {
303306
cads, cleanup := store.CADownloadStoreFixture()
304307
defer cleanup()
305308

306-
blob := core.SizedBlobFixture(1, 1)
309+
// Use two pieces to prevent triggering a blob cache move after piece
310+
// completion, which would fail as a mocked store writer is used.
311+
blob := core.SizedBlobFixture(2, 1)
312+
piece := blob.Content[:1]
307313

308314
prepareStore(cads, blob.MetaInfo)
309315

@@ -312,12 +318,12 @@ func TestTorrentWritePieceFailuresRemoveDirtyStatus(t *testing.T) {
312318
gomock.InOrder(
313319
// First write fails.
314320
w.EXPECT().Seek(int64(0), 0).Return(int64(0), nil),
315-
w.EXPECT().Write(blob.Content).Return(0, errors.New("first write error")),
321+
w.EXPECT().Write(piece).Return(0, errors.New("first write error")),
316322
w.EXPECT().Close().Return(nil),
317323

318324
// Second write succeeds.
319325
w.EXPECT().Seek(int64(0), 0).Return(int64(0), nil),
320-
w.EXPECT().Write(blob.Content).Return(len(blob.Content), nil),
326+
w.EXPECT().Write(piece).Return(len(piece), nil),
321327
w.EXPECT().Close().Return(nil),
322328
)
323329

@@ -326,8 +332,8 @@ func TestTorrentWritePieceFailuresRemoveDirtyStatus(t *testing.T) {
326332

327333
// After the first write fails, the dirty bit should be flipped to empty,
328334
// allowing future writes to succeed.
329-
require.Error(tor.WritePiece(piecereader.NewBuffer(blob.Content), 0))
330-
require.NoError(tor.WritePiece(piecereader.NewBuffer(blob.Content), 0))
335+
require.Error(tor.WritePiece(piecereader.NewBuffer(piece), 0))
336+
require.NoError(tor.WritePiece(piecereader.NewBuffer(piece), 0))
331337
}
332338

333339
func TestTorrentRestoreCompletedTorrent(t *testing.T) {

0 commit comments

Comments
 (0)