fix: atomic burn-after-read claim (#58)
CI / test (pull_request) Successful in 20s
CI / docker (pull_request) Skipped

SoftDelete now reports whether it performed the delete (conditional
UPDATE ... WHERE deleted_at IS NULL checked via RowsAffected).
RegisterRead returns an admitted flag: legacy burn pastes admit exactly
one reader (the atomic soft-delete winner), and burn-after-N pastes
increment reads_used via a conditional UPDATE guarded on
reads_used < reads_limit, so concurrent readers cannot both consume the
final read. API, HTML, and raw read paths return 404 when the reader
loses the burn claim; content is never served twice.

OpenStore pins the SQLite pool to one connection: concurrent writes on
separate pooled connections surfaced SQLITE_BUSY as spurious 500s
instead of serializing.

Adds concurrency regression tests: 24 parallel readers of a burn paste
(exactly one receives content, none of the others leak it) and 30
parallel readers vs a 3-read budget (exactly 3 admitted, then 404).
This commit is contained in:
agent
2026-09-09 09:18:42 -05:00
parent 03bf327f6b
commit 78374b2d49
5 changed files with 178 additions and 23 deletions
+103
View File
@@ -0,0 +1,103 @@
package api
import (
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
)
// TestBurnAfterReadConcurrentRace is the #58 regression test: N concurrent
// readers of a burn-after-read paste must receive exactly one success with
// content; every other reader must get 404 and never any content.
func TestBurnAfterReadConcurrentRace(t *testing.T) {
s := testServer(t)
h := s.routes()
id := createBurnReads(t, h, 1)
const readers = 24
var wg sync.WaitGroup
var mu sync.Mutex
wins, losses := 0, 0
for i := 0; i < readers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
mu.Lock()
defer mu.Unlock()
if rec.Code == 200 {
wins++
var got struct {
Content string `json:"content"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &got); err != nil || got.Content != "limited" {
t.Errorf("winning read returned wrong content: %v %q", err, got.Content)
}
} else if rec.Code == 404 {
losses++
if strings.Contains(rec.Body.String(), "limited") {
t.Errorf("losing read leaked content: %s", rec.Body.String())
}
} else {
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
}
}(i)
}
wg.Wait()
if wins != 1 {
t.Fatalf("expected exactly 1 winning read of burn paste, got %d (losses=%d)", wins, losses)
}
if losses != readers-1 {
t.Fatalf("expected %d losing reads, got %d", readers-1, losses)
}
}
// TestBurnAfterNReadsConcurrentBudget hammers a burn-after-N paste with many
// more concurrent distinct readers than the budget: total admissions must
// equal exactly N, and no losing read may see content.
func TestBurnAfterNReadsConcurrentBudget(t *testing.T) {
s := testServer(t)
h := s.routes()
const budget = 3
id := createBurnReads(t, h, budget)
const readers = 30
var wg sync.WaitGroup
var mu sync.Mutex
wins := 0
for i := 0; i < readers; i++ {
wg.Add(1)
go func(i int) {
defer wg.Done()
req := httptest.NewRequest("GET", "/api/pastes/"+id, nil)
req.AddCookie(&http.Cookie{Name: "vwr", Value: fmt.Sprintf("racer-%d", i)})
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
mu.Lock()
defer mu.Unlock()
if rec.Code == 200 {
wins++
} else if rec.Code != 404 {
t.Errorf("unexpected status %d: %s", rec.Code, rec.Body.String())
}
}(i)
}
wg.Wait()
if wins != budget {
t.Fatalf("expected exactly %d admitted reads, got %d", budget, wins)
}
// After the race, the paste is burned for everyone.
if rec := getWithCookie(t, h, id, "after-the-fact"); rec.Code != 404 {
t.Fatalf("paste should be burned after budget exhausted, got %d", rec.Code)
}
}
+19 -9
View File
@@ -29,11 +29,11 @@ type Config struct {
}
type apiServer struct {
store *store.Store
cfg Config
ui *web.UI
settings *settingsStore
adminKey string
store *store.Store
cfg Config
ui *web.UI
settings *settingsStore
adminKey string
}
func NewServer(st *store.Store, cfg Config, ui *web.UI, ss *settingsStore, adminKey string) *apiServer {
@@ -216,7 +216,12 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) {
return
}
}
rem, _ := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow()) // #49 (also covers legacy burn)
// #58: only the reader that wins the atomic burn claim may see content.
rem, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
if !admitted {
writeErr(w, 404, "paste not found")
return
}
writeJSON(w, 200, map[string]any{
"id": row.ID, "content": row.Content, "content_type": row.ContentType,
"language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt,
@@ -240,7 +245,7 @@ func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) {
writeErr(w, 403, "not your paste")
return
}
if err := a.store.SoftDelete(row.ID); err != nil {
if _, err := a.store.SoftDelete(row.ID); err != nil {
writeErr(w, 500, "db error")
return
}
@@ -319,8 +324,13 @@ func (a *apiServer) handleRaw(w http.ResponseWriter, r *http.Request) {
return
}
// #49 decision: raw reads count against the read budget too, with the
// same per-viewer dedupe window as page views.
a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
// same per-viewer dedupe window as page views. #58: a reader that loses
// the burn claim must not receive the content.
_, admitted := a.store.RegisterRead(row, currentViewerID(r), a.burnViewerWindow())
if !admitted {
http.Error(w, "not found", 404)
return
}
// #34: content_type is attacker-controlled via the create API. Serving it
// verbatim let a paste be stored with text/html (or image/svg+xml) and
// render as active content on this origin when fetched from /raw —