tinyrelayfile
tinyrelay/internal/blob/multipart_review_test.go/

internal/blob/multipart_review_test.go raw
1package blob
2
3import (
4 "context"
5 "crypto/sha256"
6 "database/sql"
7 "encoding/hex"
8 "errors"
9 "io"
10 "net/http"
11 "net/http/httptest"
12 "os"
13 "path/filepath"
14 "strings"
15 "sync"
16 "testing"
17 "time"
18)
19
20func multipartReviewRequest(sha string, length, offset int64, body string) *http.Request {
21 r := httptest.NewRequest(http.MethodPatch, "https://relay.test/"+sha, strings.NewReader(body))
22 r.Header.Set("Content-Type", "application/octet-stream")
23 r.Header.Set("Upload-Type", "application/octet-stream")
24 r.Header.Set("Upload-Length", formatInt(length))
25 r.Header.Set("Upload-Offset", formatInt(offset))
26 return r
27}
28
29func TestMultipartReviewConcurrentChunksProduceOneBlob(t *testing.T) {
30 s := testService(t)
31 body := []byte(strings.Repeat("a", 70000) + strings.Repeat("b", 70000))
32 digest := sha256.Sum256(body)
33 sha := hex.EncodeToString(digest[:])
34 parts := []struct {
35 offset int64
36 body string
37 }{
38 {70000, string(body[70000:])},
39 {0, string(body[:70000])},
40 }
41 responses := make([]*httptest.ResponseRecorder, len(parts))
42 var wg sync.WaitGroup
43 for i, part := range parts {
44 wg.Add(1)
45 go func() {
46 defer wg.Done()
47 responses[i] = record(s.Handler(), multipartReviewRequest(sha, int64(len(body)), part.offset, part.body))
48 }()
49 }
50 wg.Wait()
51 var created, partial int
52 for _, response := range responses {
53 switch response.Code {
54 case http.StatusCreated:
55 created++
56 case http.StatusNoContent:
57 partial++
58 default:
59 t.Fatalf("concurrent chunk status = %d: %s", response.Code, response.Body.String())
60 }
61 }
62 if created != 1 || partial != 1 {
63 t.Fatalf("concurrent chunk results created=%d partial=%d", created, partial)
64 }
65 entry, file, err := s.Get(context.Background(), sha)
66 if err != nil {
67 t.Fatal(err)
68 }
69 defer file.Close()
70 if entry.Size != int64(len(body)) {
71 t.Fatalf("stored size = %d, want %d", entry.Size, len(body))
72 }
73 got, err := io.ReadAll(file)
74 if err != nil || string(got) != string(body) {
75 t.Fatalf("stored body mismatch: %v", err)
76 }
77}
78
79func TestMultipartReviewStalledBodyDoesNotBlockOrdinaryUpload(t *testing.T) {
80 s := testService(t)
81 body := "stalled"
82 digest := sha256.Sum256([]byte(body))
83 sha := hex.EncodeToString(digest[:])
84 started := make(chan struct{})
85 release := make(chan struct{})
86 r := multipartReviewRequest(sha, int64(len(body)), 0, body)
87 r.Body = &multipartBlockingBody{Reader: strings.NewReader(body), started: started, release: release}
88 r.ContentLength = int64(len(body))
89 result := make(chan *httptest.ResponseRecorder, 1)
90 go func() { result <- record(s.Handler(), r) }()
91 select {
92 case <-started:
93 case <-time.After(time.Second):
94 t.Fatal("multipart body was not read")
95 }
96 if _, err := s.Put(context.Background(), PutOptions{Reader: strings.NewReader("ordinary"), Type: "text/plain", Uploader: "uploader"}); err != nil {
97 t.Fatalf("ordinary upload blocked by stalled multipart body: %v", err)
98 }
99 close(release)
100 select {
101 case response := <-result:
102 if response.Code != http.StatusCreated {
103 t.Fatalf("multipart completion status = %d", response.Code)
104 }
105 case <-time.After(time.Second):
106 t.Fatal("stalled multipart request did not finish")
107 }
108}
109
110func TestMultipartReviewStalledHashDoesNotBlockOrdinaryUpload(t *testing.T) {
111 s := testService(t)
112 body := "hash-paused"
113 digest := sha256.Sum256([]byte(body))
114 sha := hex.EncodeToString(digest[:])
115 started := make(chan struct{})
116 release := make(chan struct{})
117 s.verifyMultipartHook = func(ctx context.Context, path, hash string, size int64) error {
118 close(started)
119 <-release
120 return verifyMultipart(ctx, path, hash, size)
121 }
122 result := make(chan *httptest.ResponseRecorder, 1)
123 r := multipartReviewRequest(sha, int64(len(body)), 0, body)
124 go func() { result <- record(s.Handler(), r) }()
125 select {
126 case <-started:
127 case <-time.After(time.Second):
128 t.Fatal("multipart hash was not started")
129 }
130 ordinary := make(chan error, 1)
131 go func() {
132 _, err := s.Put(context.Background(), PutOptions{Reader: strings.NewReader("ordinary"), Type: "text/plain", Uploader: "other"})
133 ordinary <- err
134 }()
135 select {
136 case err := <-ordinary:
137 if err != nil {
138 t.Fatalf("ordinary upload while multipart hash paused: %v", err)
139 }
140 case <-time.After(time.Second):
141 t.Fatal("ordinary upload blocked by multipart hashing")
142 }
143 close(release)
144 select {
145 case response := <-result:
146 if response.Code != http.StatusCreated {
147 t.Fatalf("multipart completion status = %d: %s", response.Code, response.Body.String())
148 }
149 case <-time.After(time.Second):
150 t.Fatal("multipart hash did not finish")
151 }
152}
153
154func TestMultipartReviewStalledHashDoesNotBlockAnotherSession(t *testing.T) {
155 s := testService(t)
156 body := "hash-paused"
157 digest := sha256.Sum256([]byte(body))
158 sha := hex.EncodeToString(digest[:])
159 started := make(chan struct{})
160 release := make(chan struct{})
161 var unblock sync.Once
162 defer unblock.Do(func() { close(release) })
163 s.verifyMultipartHook = func(ctx context.Context, path, hash string, size int64) error {
164 close(started)
165 <-release
166 return verifyMultipart(ctx, path, hash, size)
167 }
168 first := make(chan *httptest.ResponseRecorder, 1)
169 go func() { first <- record(s.Handler(), multipartReviewRequest(sha, int64(len(body)), 0, body)) }()
170 select {
171 case <-started:
172 case <-time.After(5 * time.Second):
173 t.Fatal("multipart hash did not start")
174 }
175 // A different session must reserve space and commit a partial chunk while
176 // the first session hashes. Ordinary PUT does not take multipartMu and
177 // would not detect the tenant-wide finalization lock regression.
178 secondBody := strings.Repeat("b", 70000)
179 secondDigest := sha256.Sum256([]byte(secondBody + "remaining"))
180 second := make(chan *httptest.ResponseRecorder, 1)
181 go func() {
182 second <- record(s.Handler(), multipartReviewRequest(hex.EncodeToString(secondDigest[:]), int64(len(secondBody)+len("remaining")), 0, secondBody))
183 }()
184 select {
185 case response := <-second:
186 if response.Code != http.StatusNoContent {
187 t.Fatalf("second session = %d: %s", response.Code, response.Body.String())
188 }
189 case <-time.After(5 * time.Second):
190 t.Fatal("another multipart session blocked by hashing")
191 }
192 unblock.Do(func() { close(release) })
193 select {
194 case response := <-first:
195 if response.Code != http.StatusCreated {
196 t.Fatalf("finalizing session = %d: %s", response.Code, response.Body.String())
197 }
198 case <-time.After(5 * time.Second):
199 t.Fatal("finalizing session did not finish")
200 }
201}
202
203func TestMultipartReviewFinalizingSessionRejectsConcurrentChunk(t *testing.T) {
204 s := testService(t)
205 body := "same-session"
206 digest := sha256.Sum256([]byte(body))
207 sha := hex.EncodeToString(digest[:])
208 started := make(chan struct{})
209 release := make(chan struct{})
210 s.verifyMultipartHook = func(ctx context.Context, path, hash string, size int64) error {
211 close(started)
212 <-release
213 return verifyMultipart(ctx, path, hash, size)
214 }
215 first := make(chan *httptest.ResponseRecorder, 1)
216 go func() { first <- record(s.Handler(), multipartReviewRequest(sha, int64(len(body)), 0, body)) }()
217 select {
218 case <-started:
219 case <-time.After(time.Second):
220 t.Fatal("multipart hash was not started")
221 }
222 second := record(s.Handler(), multipartReviewRequest(sha, int64(len(body)), 0, body))
223 if second.Code != http.StatusConflict {
224 t.Fatalf("concurrent same-session chunk status = %d: %s", second.Code, second.Body.String())
225 }
226 close(release)
227 if response := <-first; response.Code != http.StatusCreated {
228 t.Fatalf("finalizing upload status = %d: %s", response.Code, response.Body.String())
229 }
230}
231
232func TestMultipartReviewCleanupDoesNotRemoveActiveFinalization(t *testing.T) {
233 s := testService(t)
234 body := "cleanup-paused"
235 digest := sha256.Sum256([]byte(body))
236 sha := hex.EncodeToString(digest[:])
237 started := make(chan struct{})
238 release := make(chan struct{})
239 s.verifyMultipartHook = func(ctx context.Context, path, hash string, size int64) error {
240 close(started)
241 <-release
242 return verifyMultipart(ctx, path, hash, size)
243 }
244 result := make(chan *httptest.ResponseRecorder, 1)
245 go func() { result <- record(s.Handler(), multipartReviewRequest(sha, int64(len(body)), 0, body)) }()
246 select {
247 case <-started:
248 case <-time.After(time.Second):
249 t.Fatal("multipart hash was not started")
250 }
251 if _, err := s.store.DB().Exec("UPDATE multipart_uploads SET last_seen=0"); err != nil {
252 t.Fatal(err)
253 }
254 if err := s.CleanupMultipart(context.Background()); err != nil {
255 t.Fatal(err)
256 }
257 id := multipartID(sha, "uploader")
258 if _, err := os.Stat(s.multipartPath(id)); err != nil {
259 t.Fatalf("active multipart file removed during cleanup: %v", err)
260 }
261 var found int
262 if err := s.store.DB().QueryRow("SELECT COUNT(*) FROM multipart_uploads WHERE id=?", id).Scan(&found); err != nil {
263 t.Fatal(err)
264 }
265 if found != 1 {
266 t.Fatal("active multipart reservation removed during cleanup")
267 }
268 close(release)
269 if response := <-result; response.Code != http.StatusCreated {
270 t.Fatalf("finalizing upload status = %d: %s", response.Code, response.Body.String())
271 }
272}
273
274func TestMultipartReviewPolicyRecheckedAfterHash(t *testing.T) {
275 s := testService(t)
276 body := "policy-after-hash"
277 digest := sha256.Sum256([]byte(body))
278 sha := hex.EncodeToString(digest[:])
279 started := make(chan struct{})
280 release := make(chan struct{})
281 s.verifyMultipartHook = func(ctx context.Context, path, hash string, size int64) error {
282 close(started)
283 <-release
284 return verifyMultipart(ctx, path, hash, size)
285 }
286 s.config.Limits = func() Limits { return Limits{MaxFileBytes: int64(len(body))} }
287 result := make(chan *httptest.ResponseRecorder, 1)
288 go func() { result <- record(s.Handler(), multipartReviewRequest(sha, int64(len(body)), 0, body)) }()
289 <-started
290 s.config.Limits = func() Limits { return Limits{MaxFileBytes: int64(len(body) - 1)} }
291 close(release)
292 if response := <-result; response.Code != http.StatusRequestEntityTooLarge {
293 t.Fatalf("policy-changed upload status = %d: %s", response.Code, response.Body.String())
294 }
295 if _, _, err := s.Get(context.Background(), sha); !errors.Is(err, sql.ErrNoRows) {
296 t.Fatalf("policy-rejected upload lookup = %v", err)
297 }
298}
299
300func TestMultipartReviewCanceledHashCanBeRetried(t *testing.T) {
301 s := testService(t)
302 body := "cancel-and-retry"
303 digest := sha256.Sum256([]byte(body))
304 sha := hex.EncodeToString(digest[:])
305 started := make(chan struct{})
306 release := make(chan struct{})
307 s.verifyMultipartHook = func(ctx context.Context, path, hash string, size int64) error {
308 close(started)
309 <-release
310 return verifyMultipart(ctx, path, hash, size)
311 }
312 ctx, cancel := context.WithCancel(context.Background())
313 r := multipartReviewRequest(sha, int64(len(body)), 0, body).WithContext(ctx)
314 result := make(chan *httptest.ResponseRecorder, 1)
315 go func() { result <- record(s.Handler(), r) }()
316 <-started
317 cancel()
318 close(release)
319 <-result
320 s.verifyMultipartHook = nil
321 if response := record(s.Handler(), multipartReviewRequest(sha, int64(len(body)), 0, body)); response.Code != http.StatusCreated {
322 t.Fatalf("retry after canceled hash status = %d: %s", response.Code, response.Body.String())
323 }
324}
325
326type multipartBlockingBody struct {
327 io.Reader
328 started chan<- struct{}
329 release <-chan struct{}
330 once sync.Once
331}
332
333func (b *multipartBlockingBody) Read(p []byte) (int, error) {
334 b.once.Do(func() { close(b.started) })
335 <-b.release
336 return b.Reader.Read(p)
337}
338
339func (b *multipartBlockingBody) Close() error { return nil }
340
341func TestMultipartReviewReservationsBlockUploadsAndReleaseOnExpiry(t *testing.T) {
342 s := testService(t)
343 s.config.Limits = func() Limits { return Limits{UserStorageBytes: 8} }
344 full := "12345678"
345 digest := sha256.Sum256([]byte(full))
346 sha := hex.EncodeToString(digest[:])
347 partial := multipartReviewRequest(sha, int64(len(full)), 0, full[:4])
348 partial.ContentLength = 4
349 if response := record(s.Handler(), partial); response.Code != http.StatusNoContent {
350 t.Fatalf("partial reservation status = %d: %s", response.Code, response.Body.String())
351 }
352 if _, err := s.Put(context.Background(), PutOptions{Reader: strings.NewReader("1234"), Type: "text/plain", Uploader: "uploader"}); !errors.Is(err, ErrQuotaExceeded) {
353 t.Fatalf("ordinary upload after reservation = %v", err)
354 }
355 other := "abcdefgh"
356 otherDigest := sha256.Sum256([]byte(other))
357 second := multipartReviewRequest(hex.EncodeToString(otherDigest[:]), int64(len(other)), 0, other[:4])
358 second.ContentLength = 4
359 if response := record(s.Handler(), second); response.Code != http.StatusForbidden {
360 t.Fatalf("second reserved upload status = %d", response.Code)
361 }
362 if _, err := s.store.DB().Exec("UPDATE multipart_uploads SET last_seen=0"); err != nil {
363 t.Fatal(err)
364 }
365 if err := s.CleanupMultipart(context.Background()); err != nil {
366 t.Fatal(err)
367 }
368 matches, err := filepath.Glob(filepath.Join(s.root, ".multipart-*"))
369 if err != nil {
370 t.Fatal(err)
371 }
372 if len(matches) != 0 {
373 t.Fatalf("expired multipart files remain: %v", matches)
374 }
375 if _, err := s.Put(context.Background(), PutOptions{Reader: strings.NewReader("1234"), Type: "text/plain", Uploader: "uploader"}); err != nil {
376 t.Fatalf("ordinary upload after expiry: %v", err)
377 }
378}
379
380func TestMultipartReviewHashMismatchReleasesReservation(t *testing.T) {
381 s := testService(t)
382 s.config.Limits = func() Limits { return Limits{UserStorageBytes: 5} }
383 wrong := strings.Repeat("a", 64)
384 r := multipartReviewRequest(wrong, 5, 0, "wrong")
385 r.ContentLength = 5
386 if response := record(s.Handler(), r); response.Code != http.StatusConflict {
387 t.Fatalf("hash mismatch status = %d: %s", response.Code, response.Body.String())
388 }
389 if _, err := s.Put(context.Background(), PutOptions{Reader: strings.NewReader("right"), Type: "text/plain", Uploader: "uploader"}); err != nil {
390 t.Fatalf("upload after mismatch reservation: %v", err)
391 }
392}
393
394func TestMultipartReviewExistingBlobCannotBypassModerationBlock(t *testing.T) {
395 s := testService(t)
396 body := "blocked"
397 digest := sha256.Sum256([]byte(body))
398 sha := hex.EncodeToString(digest[:])
399 if _, err := s.Put(context.Background(), PutOptions{Reader: strings.NewReader(body), Type: "text/plain"}); err != nil {
400 t.Fatal(err)
401 }
402 if _, err := s.store.DB().Exec("INSERT INTO blob_blocks(sha256,reason,blocked_at) VALUES(?,?,?)", sha, "review", time.Now().Unix()); err != nil {
403 t.Fatal(err)
404 }
405 r := multipartReviewRequest(sha, int64(len(body)), 0, body)
406 r.ContentLength = int64(len(body))
407 if response := record(s.Handler(), r); response.Code != http.StatusForbidden {
408 t.Fatalf("blocked existing blob status = %d: %s", response.Code, response.Body.String())
409 }
410}

About

ownerc01503e8035f
visibilitypublic
headd730679639bf

Maintainers

  • c01503e8035f owner
  • ba1b5beed5b5 agent hermes

Clone

  • https://012.run/npub1cq2s86qrtadf36eqq3epm38wmhdynrrcwxf84eldk3f94dkv7ttsrulrz5/tinyrelay.git

Refs