fix(submit): 上传途中投稿被撤回或删除时,由上传自己删掉刚落地的 blob

This commit is contained in:
Lemon-miaow committed 2026-09-27 13:43:41 +08:00
1 parent 372c8972f9
commit c54bd2d9eb
4 files changed
+69 -4

No files matched your search

+3 -1
View File
@@ -5858,7 +5858,9 @@ paths:
location Kaniko reads via --context. The submitter is taken from the location Kaniko reads via --context. The submitter is taken from the
principal; a submission the caller does not own is reported as 404, so principal; a submission the caller does not own is reported as 404, so
this endpoint cannot upload to or probe another user's submission. Only a this endpoint cannot upload to or probe another user's submission. Only a
pending_review submission accepts a context (409 otherwise); a wrong-format pending_review submission accepts a context (409 otherwise), and one
withdrawn or deleted while its context streams in answers 404 with the
bytes discarded; a wrong-format
or oversize body is rejected with 400 (the per-upload cap is [registry] or oversize body is rejected with 400 (the per-upload cap is [registry]
context_max_bytes, 1 GiB by default; GET /api/v1/me/submissions/limits context_max_bytes, 1 GiB by default; GET /api/v1/me/submissions/limits
reports it so a client can check a file before sending it). This reports it so a client can check a file before sending it). This
+11 -2
View File
@@ -656,8 +656,17 @@ func (m *Manager) UploadContext(ctx context.Context, id, submittedBy string, r i
return nil, err return nil, err
} }
if !won { if !won {
// Reviewed while the bytes streamed in. The blob was replaced anyway, but // The row stopped being pending while the bytes streamed in.
// the approved digest no longer matches it, so its build refuses them. if _, err := m.Store.GetSubmission(ctx, id); errors.Is(err, ErrNotFound) {
// Withdrawn or deleted: its reap ran before this blob landed, and no
// row will ever count or delete it, so the upload deletes it itself.
if err := m.deleteBlob(ctx, id); err != nil {
return nil, err
}
return nil, ErrNotFound
}
// Reviewed. The blob was replaced anyway, but the approved digest no
// longer matches it, so its build refuses them.
return nil, ErrAlreadyReviewed return nil, ErrAlreadyReviewed
} }
sub.ContextSHA256 = digest sub.ContextSHA256 = digest
+54
View File
@@ -1246,6 +1246,60 @@ func (a approvingBlobs) Put(ctx context.Context, id string, r io.Reader) (int64,
return n, err return n, err
} }
// A withdraw or delete that lands while an upload's bytes stream in reaps the
// submission before the blob exists. The upload finds its row gone and deletes
// the blob itself: nothing else would, and no row would ever count it.
func TestUploadRacingRemovalLeavesNoBlob(t *testing.T) {
for _, tc := range []struct {
name string
remove func(m *Manager, id string) error
}{
{"withdraw", func(m *Manager, id string) error {
_, err := m.Withdraw(context.Background(), id, "user-1")
return err
}},
{"delete", func(m *Manager, id string) error { _, err := m.Delete(context.Background(), id); return err }},
} {
t.Run(tc.name, func(t *testing.T) {
ctx := context.Background()
m, st, _ := newManager()
fb := newFakeBlobs()
m.Blobs = fb
seed, _ := m.Create(ctx, CreateRequest{DisplayName: "Pack", SubmittedBy: "user-1"})
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("first"))); err != nil {
t.Fatalf("first upload: %v", err)
}
m.Blobs = racingBlobs{fakeBlobs: fb, race: func() {
if err := tc.remove(m, seed.ID); err != nil {
t.Fatalf("%s: %v", tc.name, err)
}
}}
if _, err := m.UploadContext(ctx, seed.ID, "user-1", strings.NewReader(gzBody("second"))); !errors.Is(err, ErrNotFound) {
t.Fatalf("racing upload = %v, want ErrNotFound", err)
}
if _, ok := st.subs[seed.ID]; ok {
t.Fatal("the row must stay gone")
}
if len(fb.stored) != 0 {
t.Fatalf("stored blobs = %v, want none", keysOf(fb.stored))
}
})
}
}
// racingBlobs runs race before Put stores the bytes, the window a withdraw or
// delete lands in while an upload streams.
type racingBlobs struct {
*fakeBlobs
race func()
}
func (r racingBlobs) Put(ctx context.Context, id string, rd io.Reader) (int64, error) {
r.race()
return r.fakeBlobs.Put(ctx, id, rd)
}
func sha256Hex(s string) string { func sha256Hex(s string) string {
sum := sha256.Sum256([]byte(s)) sum := sha256.Sum256([]byte(s))
return hex.EncodeToString(sum[:]) return hex.EncodeToString(sum[:])
+1 -1
View File
@@ -1968,7 +1968,7 @@ export interface paths {
put?: never; put?: never;
/** /**
* Upload the modpack build context for your own pending submission (user side; user-directed lane over §16). * Upload the modpack build context for your own pending submission (user side; user-directed lane over §16).
* @description The request body IS the raw gzip build context (context.tar.gz) — not JSON, not multipart — streamed to the platform-derived, id-namespaced location Kaniko reads via --context. The submitter is taken from the principal; a submission the caller does not own is reported as 404, so this endpoint cannot upload to or probe another user's submission. Only a pending_review submission accepts a context (409 otherwise); a wrong-format or oversize body is rejected with 400 (the per-upload cap is [registry] context_max_bytes, 1 GiB by default; GET /api/v1/me/submissions/limits reports it so a client can check a file before sending it). This request carries the whole context, so behind the Cloudflare edge, whose proxy refuses bodies over 100 MB with its own HTML 413 before they reach the API, a larger context goes through the chunked upload at /api/v1/me/submissions/{id}/context/upload instead. An upload that would push the caller past their per-user stored-context budget is refused with 403 before the excess is persisted. Returns 503 when the deployment's context store has no implemented upload transport. * @description The request body IS the raw gzip build context (context.tar.gz) — not JSON, not multipart — streamed to the platform-derived, id-namespaced location Kaniko reads via --context. The submitter is taken from the principal; a submission the caller does not own is reported as 404, so this endpoint cannot upload to or probe another user's submission. Only a pending_review submission accepts a context (409 otherwise), and one withdrawn or deleted while its context streams in answers 404 with the bytes discarded; a wrong-format or oversize body is rejected with 400 (the per-upload cap is [registry] context_max_bytes, 1 GiB by default; GET /api/v1/me/submissions/limits reports it so a client can check a file before sending it). This request carries the whole context, so behind the Cloudflare edge, whose proxy refuses bodies over 100 MB with its own HTML 413 before they reach the API, a larger context goes through the chunked upload at /api/v1/me/submissions/{id}/context/upload instead. An upload that would push the caller past their per-user stored-context budget is refused with 403 before the excess is persisted. Returns 503 when the deployment's context store has no implemented upload transport.
*/ */
post: operations["uploadSubmissionContext"]; post: operations["uploadSubmissionContext"];
delete?: never; delete?: never;