fix(files): 模组包上传也逐段核对 SHA-256、长文件名能写、卡住的导出按时停掉、世界被占用时文件页说明原因并等它结束

This commit is contained in:
Lemon-miaow committed 2026-09-29 01:26:24 +08:00
1 parent 1d1549cea2
commit cb3065da1d
59 files changed
+2538 -338

No files matched your search

+3 -1
View File
@@ -464,10 +464,12 @@ func (a *API) internalAPIRoutes() []apiRoute {
// Principal); the shared enqueueBackup tail enforces the RWO stopped-gate.
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/backup", Callers: ops, h: a.handleInternalBackup},
// A file upload's staged bytes, fetched once by the Job landing them. Public
// A file upload's staged bytes, fetched once by the Job landing them, which
// then reports them landed so a file sent in parts is deleted. Public
// because that Job holds no service token; the one-time bearer token minted
// with the upload is the check (handlers_files.go).
{Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload},
{Method: "DELETE", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUploadLanded},
// An export Job's archive, held open until the owner's browser downloads
// it. Public for the same reason as file uploads: the Job holds no service
+104 -11
View File
@@ -44,6 +44,12 @@ import (
// archive passes through felis-api's memory once and never touches a disk
// it owns, and the Job moves at the browser's pace.
//
// Nothing on the way is trusted to deliver the bytes intact. The Job hashes
// what it sends and ends its chunked PUT with the SHA-256 as a trailer;
// felis-api hashes what it receives and holds the last buffer back from the
// browser until the two agree (relayExport), so a download whose bytes changed
// between the Job and here fails in the browser rather than lands complete.
//
// A backup is checked by the Job against the sha256 recorded when it was
// written (cmd/felis export): on a mismatch the Job aborts its upload short of
// the archive's end, the download aborts with it, and the browser reports a
@@ -54,9 +60,10 @@ import (
// Exporter starts the Job that archives a world or a backup and hands it to the
// internal upload route (internal/worldexport). Optional: when nil the export
// routes answer 503.
// routes answer 503. Stop deletes a Job felis-api has given up on.
type Exporter interface {
Start(ctx context.Context, r worldexport.Request) (job string, err error)
Stop(ctx context.Context, job string) error
}
// Limits on exports. Each one keeps a Job, a connection and a 64 KiB copy
@@ -176,8 +183,10 @@ type exportEntry struct {
// exportUpload is the Job's PUT, parked until a browser claims it.
type exportUpload struct {
body io.Reader
size int64 // -1 when the Job streams it chunked
body io.Reader
size int64 // what the Job declared (worldexport.LengthHeader); -1 when it did not
// digest reads the Job's trailer, which is there once body has ended.
digest func() []string
claimed chan struct{}
done chan error // how the download ended; buffered
}
@@ -191,6 +200,9 @@ type exportRegistry struct {
byTicket map[string]*exportEntry
byID map[string]*exportEntry
starts map[exportStarts][]time.Time // oldest first
// stop deletes the Job of an export the sweep expired while it was
// pending, and is run apart from the lock.
stop func(job string)
}
func (a *API) exportTickets() *exportRegistry {
@@ -199,17 +211,41 @@ func (a *API) exportTickets() *exportRegistry {
byTicket: map[string]*exportEntry{},
byID: map[string]*exportEntry{},
starts: map[exportStarts][]time.Time{},
stop: a.stopExportJob,
}
})
return a.exports
}
// ExpireExports runs the export sweep on its own, for felis-api's loop: the
// routes sweep as they are called, and an owner who closed the tab calls none.
func (a *API) ExpireExports() {
g := a.exportTickets()
g.mu.Lock()
defer g.mu.Unlock()
g.sweepLocked(a.now())
}
// stopExportJob deletes one export Job. A delete that fails leaves the Job to
// its own deadline.
func (a *API) stopExportJob(job string) {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := a.Exporter.Stop(ctx, job); err != nil {
log.Printf("api: stop export job %s, which never connected: %v", job, err)
}
}
// sweepLocked expires what has waited too long and forgets what ended long ago.
// A Job still pending at its TTL never connected: its Pod is stuck unscheduled
// or pulling, and until its deadline it would keep the server from starting,
// so it is deleted.
func (g *exportRegistry) sweepLocked(now time.Time) {
for t, e := range g.byTicket {
switch {
case e.state == exportPending && now.Sub(e.at) >= exportPendingTTL:
e.state, e.at = exportSpent, now
go g.stop(e.job)
case !e.active() && now.Sub(e.at) >= exportKeepSpent:
delete(g.byTicket, t)
delete(g.byID, e.id)
@@ -661,7 +697,7 @@ func (a *API) handleExportDownload(w http.ResponseWriter, r *http.Request) {
}
w.WriteHeader(http.StatusOK)
err = copyExport(w, e.upload.body)
n, sum, err := relayExport(w, e.upload)
e.upload.done <- err
if err != nil {
log.Printf("api: export %s of %s ended early: %v", e.id, e.server, err)
@@ -669,17 +705,64 @@ func (a *API) handleExportDownload(w http.ResponseWriter, r *http.Request) {
// read as failed in the browser, never as a complete file.
panic(http.ErrAbortHandler)
}
log.Printf("api: export %s of %s sent %d bytes, sha256 %s", e.id, e.server, n, sum)
}
// copyExport copies body into w through a fixed 32 KiB buffer, restarting w's
// write deadline on every write.
func copyExport(w http.ResponseWriter, body io.Reader) error {
var (
errExportDigest = errors.New("the bytes the export Job sent do not match the SHA-256 it sent with them")
errExportLength = errors.New("the export Job sent a different number of bytes than it declared")
)
// relayExport copies the Job's upload into the download through fixed 32 KiB
// buffers, restarting w's write deadline on every write, and checks it on the
// way: the bytes must hash to the SHA-256 the Job's trailer carries once it has
// sent them all (worldexport.DigestTrailer), and number what it declared, when
// it did. The latest buffer read is held back until both hold, so bytes changed
// between the Job and here, or a Job that sent no digest, end the download
// short of its end, and the browser reports it failed rather than keep a
// complete-looking corrupt file. It returns the bytes sent and their SHA-256.
func relayExport(w http.ResponseWriter, up *exportUpload) (int64, string, error) {
out := &stallWriter{w: w, rc: http.NewResponseController(w)}
if _, err := io.CopyBuffer(out, body, make([]byte, exportCopyBuffer)); err != nil {
return err
h := sha256.New()
var n int64
next, spare := make([]byte, exportCopyBuffer), make([]byte, exportCopyBuffer)
var held []byte
for {
k, err := up.body.Read(next)
if k > 0 {
if len(held) > 0 {
if _, werr := out.Write(held); werr != nil {
return n, "", werr
}
}
h.Write(next[:k])
n += int64(k)
held = next[:k]
next, spare = spare, next
}
if err == io.EOF {
break
}
if err != nil {
return n, "", err
}
}
want, err := parseContentDigest(up.digest())
switch {
case up.size >= 0 && n != up.size:
return n, "", fmt.Errorf("%w: %d of %d", errExportLength, n, up.size)
case err != nil:
return n, "", fmt.Errorf("%w: %v", errExportDigest, err)
case subtle.ConstantTimeCompare(h.Sum(nil), want) != 1:
return n, "", errExportDigest
}
if len(held) > 0 {
if _, err := out.Write(held); err != nil {
return n, "", err
}
}
_ = out.rc.SetWriteDeadline(time.Time{}) // the connection may serve another request
return nil
return n, hex.EncodeToString(h.Sum(nil)), nil
}
// stallWriter restarts the connection's write deadline before every write, so
@@ -708,9 +791,19 @@ func (a *API) handleInternalExportUpload(w http.ResponseWriter, r *http.Request)
writeError(w, r, errNoExport())
return
}
size := int64(-1)
if v := r.Header.Get(worldexport.LengthHeader); v != "" {
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "%s must be a byte count", worldexport.LengthHeader))
return
}
size = n
}
rc := takeBodyDeadline(w, r)
up := &exportUpload{
body: &stallBody{r: r.Body, rc: rc}, size: r.ContentLength,
body: &stallBody{r: r.Body, rc: rc}, size: size,
digest: func() []string { return r.Trailer.Values(worldexport.DigestTrailer) },
claimed: make(chan struct{}), done: make(chan error, 1),
}
reg := a.exportTickets()
+125 -14
View File
@@ -31,6 +31,14 @@ import (
type fakeExporter struct {
reqs []worldexport.Request
err error
// stopped receives each Job Stop is asked to delete; the sweep asks from
// a goroutine of its own.
stopped chan string
}
func (f *fakeExporter) Stop(_ context.Context, job string) error {
f.stopped <- job
return nil
}
func (f *fakeExporter) Start(_ context.Context, r worldexport.Request) (string, error) {
@@ -92,7 +100,7 @@ func exportFixture() (*API, *fakeRepo, *fakeCluster, *fakeExporter) {
for _, name := range []string{"survival", "gamma"} {
cl.byName[name] = &ServerInfo{Name: name, Phase: "Stopped", DesiredState: string(v1alpha1.DesiredStopped)}
}
ex := &fakeExporter{}
ex := &fakeExporter{stopped: make(chan string, 64)}
a := newTestAPI(repo, cl)
a.External = exportUsers
a.Exporter, a.InternalBaseURL = ex, exportBase
@@ -140,11 +148,15 @@ func waitExportReady(t *testing.T, h http.Handler, ticket, user string) {
}
// uploadExport serves the Job's PUT on the internal face in the background.
func uploadExport(h http.Handler, id, token string, body io.Reader) <-chan *httptest.ResponseRecorder {
// digest, when set, is the trailer the Job sends once its body has ended.
func uploadExport(h http.Handler, id, token string, body io.Reader, digest string) <-chan *httptest.ResponseRecorder {
out := make(chan *httptest.ResponseRecorder, 1)
go func() {
r := httptest.NewRequest("PUT", "/api/v1/internal/exports/"+id, body)
r.Header.Set("Authorization", "Bearer "+token)
if digest != "" {
r.Trailer = http.Header{worldexport.DigestTrailer: {digest}}
}
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
out <- w
@@ -167,6 +179,18 @@ func awaitUpload(t *testing.T, up <-chan *httptest.ResponseRecorder) *httptest.R
// claimed the export by mistake would block on the parked body, and an upload
// let in by mistake would wait for a browser, so either fails the test after a
// bound instead of hanging it.
// awaitStop returns the next Job the sweep asked to delete.
func awaitStop(t *testing.T, ex *fakeExporter) string {
t.Helper()
select {
case job := <-ex.stopped:
return job
case <-time.After(5 * time.Second):
t.Fatal("no Job was stopped")
return ""
}
}
func doSoon(t *testing.T, h http.Handler, method, path, body string, headers map[string]string) *httptest.ResponseRecorder {
t.Helper()
out := make(chan *httptest.ResponseRecorder, 1)
@@ -341,7 +365,7 @@ func TestExportWorldGate(t *testing.T) {
w := do(a.ExternalHandler(), "POST", worldPath, "", as("admin1"))
var raw map[string]map[string]string
_ = json.Unmarshal(w.Body.Bytes(), &raw)
if want := "a world export is running on this server's world; retry once it finishes"; w.Code != http.StatusConflict ||
if want := "a world export or file download is running on this server's world; retry once it finishes"; w.Code != http.StatusConflict ||
raw["error"]["code"] != "maintenance_in_progress" || raw["error"]["message"] != want {
t.Fatalf("busy = %d %s, want 409 %q", w.Code, w.Body.String(), want)
}
@@ -459,9 +483,17 @@ func TestExportRendezvous(t *testing.T) {
t.Fatalf("PUT to another id = %d", w.Code)
}
// A length that is no byte count is refused before the token is spent.
for _, bad := range []string{"-1", "12abc", "0x10"} {
if w := doSoon(t, in, "PUT", "/api/v1/internal/exports/"+job.ID, "x", map[string]string{
"Authorization": "Bearer " + job.Token, worldexport.LengthHeader: bad}); w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" {
t.Fatalf("PUT declaring %q bytes = %d %s", bad, w.Code, w.Body.String())
}
}
archive := randomBytes(3*exportCopyBuffer + 4321)
pr, pw := io.Pipe()
up := uploadExport(in, job.ID, job.Token, pr)
up := uploadExport(in, job.ID, job.Token, pr, contentDigestOf(string(archive)))
waitExportReady(t, ext, v.Ticket, "owner1")
if w := doSoon(t, in, "PUT", "/api/v1/internal/exports/"+job.ID, "x", map[string]string{"Authorization": "Bearer " + job.Token}); w.Code != http.StatusNotFound {
t.Fatalf("a second PUT with the spent token = %d, want 404", w.Code)
@@ -541,11 +573,47 @@ func TestExportExpiry(t *testing.T) {
t.Fatalf("past the pending TTL: %d %+v", code, s)
}
job := ex.reqs[0]
if got := awaitStop(t, ex); got != worldexport.JobName(job.Server, job.ID) {
t.Fatalf("stopped %q, want the export's own Job", got)
}
if w := doSoon(t, a.InternalHandler(), "PUT", "/api/v1/internal/exports/"+job.ID, "x", map[string]string{"Authorization": "Bearer " + job.Token}); w.Code != http.StatusNotFound {
t.Fatalf("a late Job's PUT = %d, want 404", w.Code)
}
})
// The owner closed the tab, so no route sweeps; felis-api's loop does.
t.Run("the loop stops a Job left pending, and only that one", func(t *testing.T) {
a, _, _, ex := exportFixture()
var clock atomic.Int64
clock.Store(1_700_000_000)
a.Now = func() time.Time { return time.Unix(clock.Load(), 0) }
ext := a.ExternalHandler()
beginExport(t, ext, worldPath, "owner1")
stuck := ex.reqs[0]
v := beginExport(t, ext, "/api/v1/servers/creative/backups/bk2/export", "admin1")
moving := ex.reqs[1]
uploadExport(a.InternalHandler(), moving.ID, moving.Token, strings.NewReader("archive"), contentDigestOf("archive"))
waitExportReady(t, ext, v.Ticket, "admin1")
clock.Add(int64(exportPendingTTL/time.Second) - 1)
a.ExpireExports()
select {
case job := <-ex.stopped:
t.Fatalf("stopped %s inside the pending TTL", job)
case <-time.After(50 * time.Millisecond):
}
clock.Add(1)
a.ExpireExports()
if got := awaitStop(t, ex); got != worldexport.JobName(stuck.Server, stuck.ID) {
t.Fatalf("stopped %q, want the Job that never connected", got)
}
select {
case job := <-ex.stopped:
t.Fatalf("also stopped %s, whose upload was waiting for its browser", job)
case <-time.After(50 * time.Millisecond):
}
})
t.Run("a browser that never comes", func(t *testing.T) {
defer func(old time.Duration) { exportClaimTTL = old }(exportClaimTTL)
exportClaimTTL = 30 * time.Millisecond
@@ -553,7 +621,7 @@ func TestExportExpiry(t *testing.T) {
ext := a.ExternalHandler()
v := beginExport(t, ext, backupPath, "owner1")
job := ex.reqs[0]
w := awaitUpload(t, uploadExport(a.InternalHandler(), job.ID, job.Token, strings.NewReader("archive")))
w := awaitUpload(t, uploadExport(a.InternalHandler(), job.ID, job.Token, strings.NewReader("archive"), contentDigestOf("archive")))
if w.Code != http.StatusGone || decodeErr(t, w) != "export_expired" {
t.Fatalf("unclaimed upload = %d %s, want 410 export_expired", w.Code, w.Body.String())
}
@@ -639,12 +707,20 @@ func exportServers(t *testing.T, a *API) (ext, in *httptest.Server) {
return ext, in
}
func realUpload(t *testing.T, in *httptest.Server, job worldexport.Request, body io.Reader, size int64) <-chan *http.Response {
// realUpload sends the Job's PUT as cmd/felis export does: chunked, with the
// size when it is known (not -1) and, when set, digest as the trailer.
func realUpload(t *testing.T, in *httptest.Server, job worldexport.Request, body io.Reader, size int64, digest string) <-chan *http.Response {
req, err := http.NewRequest("PUT", in.URL+"/api/v1/internal/exports/"+job.ID, body)
if err != nil {
t.Fatal(err)
}
req.ContentLength = size
req.ContentLength = -1
if size >= 0 {
req.Header.Set(worldexport.LengthHeader, strconv.FormatInt(size, 10))
}
if digest != "" {
req.Trailer = http.Header{worldexport.DigestTrailer: {digest}}
}
req.Header.Set("Authorization", "Bearer "+job.Token)
out := make(chan *http.Response, 1)
go func() {
@@ -696,7 +772,7 @@ func TestExportStreamsWhatTheJobSends(t *testing.T) {
a, _, _, ex := exportFixture()
ext, in := exportServers(t, a)
v := beginExport(t, a.ExternalHandler(), backupPath, "owner1")
up := realUpload(t, in, ex.reqs[0], bytes.NewReader(archive), int64(len(archive)))
up := realUpload(t, in, ex.reqs[0], bytes.NewReader(archive), int64(len(archive)), contentDigestOf(string(archive)))
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
resp, got, err := realDownload(ext, v.Ticket)
if resp == nil {
@@ -723,7 +799,7 @@ func TestExportStreamsWhatTheJobSends(t *testing.T) {
_, _ = pw.Write(archive[:len(archive)-100])
pw.CloseWithError(errors.New("the backup archive does not match the sha256 recorded when it was written"))
}()
up := realUpload(t, in, ex.reqs[0], pr, -1)
up := realUpload(t, in, ex.reqs[0], pr, -1, contentDigestOf(string(archive)))
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
resp, got, err := realDownload(ext, v.Ticket)
if resp == nil {
@@ -737,11 +813,46 @@ func TestExportStreamsWhatTheJobSends(t *testing.T) {
}
})
// The Job's trailer is the SHA-256 of what it sent. Bytes that arrive
// otherwise, or without it, or not as many as it declared, never reach the
// browser to their end, and the Job hears the download failed.
t.Run("bytes changed on the way never reach the browser whole", func(t *testing.T) {
flipped := bytes.Clone(archive)
flipped[len(flipped)/2] ^= 1
for _, c := range []struct {
name, digest string
size int64
}{
{"another archive's digest", contentDigestOf(string(flipped)), int64(len(archive))},
{"no digest", "", -1},
{"a digest that is no SHA-256", "sha-256=:AAAA:", -1},
{"one byte more declared than sent", contentDigestOf(string(archive)), int64(len(archive)) + 1},
} {
t.Run(c.name, func(t *testing.T) {
a, _, _, ex := exportFixture()
ext, in := exportServers(t, a)
v := beginExport(t, a.ExternalHandler(), backupPath, "owner1")
up := realUpload(t, in, ex.reqs[0], bytes.NewReader(archive), c.size, c.digest)
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
resp, got, err := realDownload(ext, v.Ticket)
if resp == nil {
t.Fatalf("download: %v", err)
}
if err == nil || len(got) >= len(archive) || !bytes.Equal(got, archive[:len(got)]) {
t.Fatalf("read %d of %d bytes, err %v; want an error short of the end", len(got), len(archive), err)
}
if upResp := awaitResponse(t, up); upResp.StatusCode != http.StatusGone {
t.Fatalf("the upload answered %d, want 410", upResp.StatusCode)
}
})
}
})
t.Run("a browser that leaves early is what the Job hears", func(t *testing.T) {
a, _, _, ex := exportFixture()
ext, _ := exportServers(t, a)
v := beginExport(t, a.ExternalHandler(), worldPath, "owner1")
up := uploadExport(a.InternalHandler(), ex.reqs[0].ID, ex.reqs[0].Token, io.LimitReader(zeros{}, 1<<30))
up := uploadExport(a.InternalHandler(), ex.reqs[0].ID, ex.reqs[0].Token, io.LimitReader(zeros{}, 1<<30), "")
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
req, _ := http.NewRequest("GET", ext.URL+"/api/v1/exports/"+v.Ticket+"/download", nil)
req.Header.Set("X-Test-User", "owner1")
@@ -957,7 +1068,7 @@ func TestFileDownloadServed(t *testing.T) {
// Past what the server would buffer and measure itself when the
// handler sets no length.
body := randomBytes(64 << 10)
up := realUpload(t, in, ex.reqs[0], bytes.NewReader(body), tc.size)
up := realUpload(t, in, ex.reqs[0], bytes.NewReader(body), tc.size, contentDigestOf(string(body)))
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
resp, got, err := realDownload(ext, v.Ticket)
if resp == nil || err != nil || !bytes.Equal(got, body) {
@@ -1015,7 +1126,7 @@ func TestExportUploadPace(t *testing.T) {
_, _ = pw.Write(archive[1000:])
pw.Close()
}()
up := realUpload(t, in, ex.reqs[0], pr, -1)
up := realUpload(t, in, ex.reqs[0], pr, -1, contentDigestOf(string(archive)))
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
time.Sleep(3 * bodyGrace)
_, got, err := realDownload(ext, v.Ticket)
@@ -1034,7 +1145,7 @@ func TestExportUploadPace(t *testing.T) {
pr, pw := io.Pipe()
defer pw.Close()
go func() { _, _ = pw.Write(make([]byte, 1000)) }() // then nothing, ever
up := realUpload(t, in, ex.reqs[0], pr, -1)
up := realUpload(t, in, ex.reqs[0], pr, -1, "")
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
done := make(chan error, 1)
go func() { _, _, err := realDownload(ext, v.Ticket); done <- err }()
@@ -1054,7 +1165,7 @@ func TestExportUploadPace(t *testing.T) {
a, _, _, ex := exportFixture()
ext, in := exportServers(t, a)
v := beginExport(t, a.ExternalHandler(), worldPath, "owner1")
realUpload(t, in, ex.reqs[0], io.LimitReader(zeros{}, 1<<30), -1)
realUpload(t, in, ex.reqs[0], io.LimitReader(zeros{}, 1<<30), -1, "")
waitExportReady(t, a.ExternalHandler(), v.Ticket, "owner1")
req, _ := http.NewRequest("GET", ext.URL+"/api/v1/exports/"+v.Ticket+"/download", nil)
req.Header.Set("X-Test-User", "owner1")
+64 -15
View File
@@ -29,17 +29,50 @@ import (
// the world volume while it runs, as any file write does, and the server cannot
// start until it ends.
// fileSessionView is where an upload session stands.
// fileSessionView is where an upload session stands. Parts are the parts it
// took, in order, each with the SHA-256 it arrived with: a client resuming from
// a file on disk checks the file still holds those bytes before it sends the
// rest.
type fileSessionView struct {
ID string `json:"id"`
Path string `json:"path"`
Size int64 `json:"size"`
Received int64 `json:"received"`
PartMaxBytes int64 `json:"part_max_bytes"`
ID string `json:"id"`
Path string `json:"path"`
Size int64 `json:"size"`
Received int64 `json:"received"`
PartMaxBytes int64 `json:"part_max_bytes"`
Parts []filePartView `json:"parts"`
}
// filePartView is one part a session took.
type filePartView struct {
Size int64 `json:"size"`
SHA256 string `json:"sha256"`
}
func sessionView(s fileedit.Session) fileSessionView {
return fileSessionView{ID: s.ID, Path: s.Path, Size: s.Size, Received: s.Received, PartMaxBytes: fileedit.PartBytes}
parts := make([]filePartView, 0, len(s.Parts))
for _, p := range s.Parts {
parts = append(parts, filePartView{Size: p.Size, SHA256: p.SHA256})
}
return fileSessionView{
ID: s.ID, Path: s.Path, Size: s.Size, Received: s.Received,
PartMaxBytes: fileedit.PartBytes, Parts: parts,
}
}
// namesFit reports whether every name in path fits one folder entry.
func namesFit(path string) bool {
for _, name := range strings.Split(path, "/") {
if len(name) > fileedit.NameMax {
return false
}
}
return true
}
// errNameTooLong refuses a path namesFit rejects, before any byte is taken:
// the Job would only find out once it tried to create the file.
func errNameTooLong() *apiError {
return newError(http.StatusBadRequest, "bad_path", "a name in the path is longer than %d bytes", fileedit.NameMax)
}
// beginFileUploadRequest is the POST …/files/uploads body.
@@ -73,6 +106,10 @@ func (a *API) handleBeginFileUpload(w http.ResponseWriter, r *http.Request) {
"the path must name a file inside the world folder"))
return
}
if !namesFit(path) {
writeError(w, r, errNameTooLong())
return
}
var body beginFileUploadRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
@@ -111,9 +148,11 @@ func (a *API) handleFileUploadStatus(w http.ResponseWriter, r *http.Request) {
// /api/v1/servers/{name}/files/uploads/{id}?offset=… — append the raw body to
// the caller's session. offset must be where the session ends (409
// upload_offset_mismatch otherwise; the status says where), and Content-Length
// is required, as for the one-request upload: the part is taken whole or not at
// all, and a part that breaks midway leaves the session where it was. A part
// over fileedit.PartBytes is refused before a byte of it is read (Append).
// and Content-Digest are required, as for the one-request upload: the part is
// taken whole or not at all, and a part that breaks midway, or whose bytes do
// not hash to its digest (400 digest_mismatch), leaves the session where it
// was. A part over fileedit.PartBytes is refused before a byte of it is read
// (Append).
func (a *API) handleFileUploadPart(w http.ResponseWriter, r *http.Request) {
name, user, ok := a.authorizeFileSession(w, r)
if !ok {
@@ -130,7 +169,11 @@ func (a *API) handleFileUploadPart(w http.ResponseWriter, r *http.Request) {
"a part needs a Content-Length"))
return
}
s, err := a.FileStage.Append(user, name, r.PathValue("id"), offset, r.Body, r.ContentLength)
want, ok := contentDigest(w, r)
if !ok {
return
}
s, err := a.FileStage.Append(user, name, r.PathValue("id"), offset, r.Body, r.ContentLength, want)
if err != nil {
writeFileSessionError(w, r, err)
return
@@ -166,8 +209,9 @@ type startFileOpRequest struct {
// The world lock is taken BEFORE the session is sealed: a commit made while an
// earlier commit's Job is still fetching the file is refused by that Job's hold
// on the volume, so it never mints the fresh token that would lock the running
// Job out. The session outlives a Job that fails before fetching every byte, so
// such a commit is simply made again; one that fetched them all is gone.
// Job out. The session outlives a Job that fails for any reason, so such a
// commit is simply made again; the Job whose file landed deletes it
// (handleInternalFileUploadLanded).
func (a *API) handleCommitFileUpload(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
@@ -335,15 +379,18 @@ func opView(op fileedit.OpState) fileOpView {
}
// opError maps a failed op onto the API's codes. A Job that printed no result
// carries only its condition's reason (DeadlineExceeded, BackoffLimitExceeded):
// carries only its reason (DeadlineExceeded, BackoffLimitExceeded, OOMKilled):
// the log it left is the world's content and the runtime's, and none of it is
// the caller's to read.
func opError(op fileedit.OpState) *fileOpError {
res := op.Result
if res == nil {
msg := "the file operation stopped before it could report how it went (%s); run it again"
if op.Reason == "DeadlineExceeded" {
switch op.Reason {
case "DeadlineExceeded":
msg = "the file operation ran out of time (%s); run it again"
case fileedit.ReasonOOMKilled:
msg = "the file operation ran out of memory (%s); an archive of this many files has to be split into smaller ones"
}
return &fileOpError{Code: "job_failed", Message: fmt.Sprintf(msg, op.Reason)}
}
@@ -434,6 +481,8 @@ func writeFileSessionError(w http.ResponseWriter, r *http.Request, err error) {
case errors.Is(err, fileedit.ErrPartTooLarge):
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "part_too_large",
"the part is larger than part_max_bytes, or runs past the size the upload began with"))
case errors.Is(err, fileedit.ErrDigestMismatch):
writeError(w, r, errDigestMismatch())
case errors.Is(err, fileedit.ErrShortUpload):
writeError(w, r, newError(http.StatusBadRequest, "upload_incomplete",
"the part ended before its Content-Length; read where the upload stands and send it again"))
+87 -7
View File
@@ -56,10 +56,19 @@ func sessionAnswer(t *testing.T, w *httptest.ResponseRecorder) fileSessionView {
}
// doPart sends one part with the Content-Length given, whatever the body's own
// length: -1 sends none, and one past the body is a part cut short.
// length: -1 sends none, and one past the body is a part cut short. Its
// Content-Digest is that of the body.
func doPart(h http.Handler, target, body string, length int64) *httptest.ResponseRecorder {
return doPartDigest(h, target, body, length, contentDigestOf(body))
}
// doPartDigest is doPart with the Content-Digest given; empty sends none.
func doPartDigest(h http.Handler, target, body string, length int64, digest string) *httptest.ResponseRecorder {
r := httptest.NewRequest("PUT", target, strings.NewReader(body))
r.Header.Set("Content-Type", "application/octet-stream")
if digest != "" {
r.Header.Set("Content-Digest", digest)
}
r.ContentLength = length
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
@@ -108,7 +117,10 @@ func TestFileUploadSession(t *testing.T) {
h := api.ExternalHandler()
s := beginSession(t, api, 10)
if len(s.ID) != 32 || s.Path != sessionPath || s.Size != 10 || s.Received != 0 || s.PartMaxBytes != fileedit.PartBytes {
// A new session lists its parts as [], which decodes non-nil; null would
// leave a client resuming with nothing to walk.
if len(s.ID) != 32 || s.Path != sessionPath || s.Size != 10 || s.Received != 0 || s.PartMaxBytes != fileedit.PartBytes ||
s.Parts == nil || len(s.Parts) != 0 {
t.Fatalf("begin = %+v", s)
}
@@ -129,8 +141,10 @@ func TestFileUploadSession(t *testing.T) {
t.Fatalf("early commit: code = %d calls = %d acquired %v (%s)", w.Code, files.calls, cl.acquired, w.Body.String())
}
if w := status(api, s.ID); w.Code != http.StatusOK || sessionAnswer(t, w) != (fileSessionView{
ID: s.ID, Path: sessionPath, Size: 10, Received: 5, PartMaxBytes: fileedit.PartBytes}) {
hello := sha256.Sum256([]byte("hello"))
if w := status(api, s.ID); w.Code != http.StatusOK || !reflect.DeepEqual(sessionAnswer(t, w), fileSessionView{
ID: s.ID, Path: sessionPath, Size: 10, Received: 5, PartMaxBytes: fileedit.PartBytes,
Parts: []filePartView{{Size: 5, SHA256: hex.EncodeToString(hello[:])}}}) {
t.Fatalf("status: code = %d (%s)", w.Code, w.Body.String())
}
if w := doPart(h, partAt(s.ID, 5), "world", 5); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 10 {
@@ -172,11 +186,31 @@ func TestFileUploadSession(t *testing.T) {
if again := fetchStaged(api, src); again.Code != http.StatusNotFound {
t.Fatalf("second fetch: code = %d", again.Code)
}
// Served whole, so the session is gone and its file with it.
if w := status(api, s.ID); w.Code != http.StatusNotFound || decodeErr(t, w) != "upload_not_found" {
// Served whole, the session stays until the Job says the file landed: a
// Job that fails after its fetch leaves the upload to be committed again.
if w := status(api, s.ID); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 10 {
t.Fatalf("status after the fetch: code = %d (%s)", w.Code, w.Body.String())
}
landed := func(token string) *httptest.ResponseRecorder {
return do(api.InternalHandler(), "DELETE", strings.TrimPrefix(src.URL, api.InternalBaseURL), "",
map[string]string{"Authorization": "Bearer " + token})
}
if w := landed(strings.Repeat("0", len(src.Token))); w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
t.Fatalf("landed with the wrong token: code = %d (%s)", w.Code, w.Body.String())
}
if w := status(api, s.ID); w.Code != http.StatusOK {
t.Fatalf("a wrong token let go of the session: code = %d (%s)", w.Code, w.Body.String())
}
if w := landed(src.Token); w.Code != http.StatusNoContent {
t.Fatalf("landed: code = %d (%s)", w.Code, w.Body.String())
}
if w := status(api, s.ID); w.Code != http.StatusNotFound || decodeErr(t, w) != "upload_not_found" {
t.Fatalf("status after it landed: code = %d (%s)", w.Code, w.Body.String())
}
stageEmpty(t, api)
if w := landed(src.Token); w.Code != http.StatusNotFound {
t.Fatalf("landed twice: code = %d (%s)", w.Code, w.Body.String())
}
})
t.Run("a Job that never fetched is committed again with a fresh token", func(t *testing.T) {
@@ -260,6 +294,32 @@ func TestFileUploadSession(t *testing.T) {
}
})
t.Run("a part changed on the way is refused and taken back", func(t *testing.T) {
api, _, _, _ := mkFiles(t)
api.External = staticExternal{p: fileOwner}
h := api.ExternalHandler()
s := beginSession(t, api, 10)
doPart(h, partAt(s.ID, 0), "hello", 5)
for _, c := range []struct {
name, digest, errCode string
}{
{"another part's digest", contentDigestOf("wor1d"), "digest_mismatch"},
{"no digest", "", "digest_required"},
{"a malformed digest", "sha-256=:bm90IGEgc3VtCg==:", "bad_digest"},
} {
w := doPartDigest(h, partAt(s.ID, 5), "world", 5, c.digest)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != c.errCode {
t.Fatalf("%s: code = %d (%s), want 400 %s", c.name, w.Code, w.Body.String(), c.errCode)
}
if got := sessionAnswer(t, status(api, s.ID)); got.Received != 5 || len(got.Parts) != 1 {
t.Fatalf("%s: session after the refused part = %+v", c.name, got)
}
}
if w := doPart(h, partAt(s.ID, 5), "world", 5); w.Code != http.StatusOK || sessionAnswer(t, w).Received != 10 {
t.Fatalf("the part sent again: code = %d (%s)", w.Code, w.Body.String())
}
})
t.Run("a part cut short leaves the session where it was", func(t *testing.T) {
api, _, _, _ := mkFiles(t)
api.External = staticExternal{p: fileOwner}
@@ -283,6 +343,7 @@ func TestFileUploadSession(t *testing.T) {
go func() {
r := httptest.NewRequest("PUT", partAt(s.ID, 0), pr)
r.Header.Set("Content-Type", "application/octet-stream")
r.Header.Set("Content-Digest", contentDigestOf("abc"))
r.ContentLength = 3
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
@@ -432,6 +493,8 @@ func TestFileUploadSession(t *testing.T) {
{"a path leaving the world", sessionsRoute + "?path=../a.jar", `{"size":3}`, nil, http.StatusBadRequest, "bad_path"},
{"an absolute path", sessionsRoute + "?path=/etc/a.jar", `{"size":3}`, nil, http.StatusBadRequest, "bad_path"},
{"the world folder itself", sessionsRoute + "?path=plugins/..", `{"size":3}`, nil, http.StatusBadRequest, "bad_path"},
{"a name longer than a folder entry holds", sessionsRoute + "?path=plugins/" + strings.Repeat("n", fileedit.NameMax-3) + ".jar", `{"size":3}`,
nil, http.StatusBadRequest, "bad_path"},
{"a staging disk at its floor", sessionsRoute + "?path=a.jar", `{"size":3}`,
func(a *API) { a.FileStage.MinFree = 1 }, http.StatusInsufficientStorage, "upload_staging_full"},
{"no stage", sessionsRoute + "?path=a.jar", `{"size":3}`,
@@ -459,6 +522,19 @@ func TestFileUploadSession(t *testing.T) {
}
})
t.Run("a name as long as a folder entry holds is taken", func(t *testing.T) {
api, _, _, _ := mkFiles(t)
api.External = staticExternal{p: fileOwner}
long := strings.Repeat("n", fileedit.NameMax-4) + ".jar"
w := do(api.ExternalHandler(), "POST", sessionsRoute+"?path=plugins/"+long, `{"size":3}`, jsonHeader)
if w.Code != http.StatusCreated {
t.Fatalf("code = %d (%s), want 201", w.Code, w.Body.String())
}
if s := sessionAnswer(t, w); s.Path != "plugins/"+long {
t.Fatalf("path = %q", s.Path)
}
})
t.Run("a fifth upload at once -> 429", func(t *testing.T) {
api, _, _, _ := mkFiles(t)
api.External = staticExternal{p: fileOwner}
@@ -686,7 +762,9 @@ func TestFileOps(t *testing.T) {
deadline.Reason = "DeadlineExceeded"
killed := base("i", fileedit.OpUpload, fileedit.OpFailed)
killed.Reason = "BackoffLimitExceeded"
files.ops = []fileedit.OpState{running, unzipped, uploaded, exists, full, changed, unsafe, deadline, killed}
oom := base("j", fileedit.OpUnzip, fileedit.OpFailed)
oom.Reason = "OOMKilled"
files.ops = []fileedit.OpState{running, unzipped, uploaded, exists, full, changed, unsafe, deadline, killed, oom}
w := list(api)
if w.Code != http.StatusOK || files.gotServer != "survival" {
@@ -725,6 +803,8 @@ func TestFileOps(t *testing.T) {
"the file operation ran out of time (DeadlineExceeded); run it again", nil)),
op("i", "upload", "failed", failure("job_failed",
"the file operation stopped before it could report how it went (BackoffLimitExceeded); run it again", nil)),
op("j", "unzip", "failed", failure("job_failed",
"the file operation ran out of memory (OOMKilled); an archive of this many files has to be split into smaller ones", nil)),
}}
if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) {
gotJSON, _ := json.MarshalIndent(got, "", " ")
+105 -10
View File
@@ -2,6 +2,8 @@ package api
import (
"context"
"crypto/sha256"
"encoding/base64"
"errors"
"io"
"net/http"
@@ -109,9 +111,14 @@ func (a *API) handleListFiles(w http.ResponseWriter, r *http.Request) {
ls.Entries = []fileedit.Entry{} // an empty directory is [], never null
}
// free_bytes lets the panel refuse an upload the volume cannot take before
// sending any of it; the Job that lands it checks again.
// sending any of it; the Job that lands it checks again. It is null when the
// Job could not read it, so a full volume (0) is never mistaken for that.
var free any = ls.Free
if ls.Free < 0 {
free = nil
}
writeJSON(w, http.StatusOK, map[string]any{
"path": path, "entries": ls.Entries, "truncated": ls.Truncated, "free_bytes": ls.Free,
"path": path, "entries": ls.Entries, "truncated": ls.Truncated, "free_bytes": free,
})
}
@@ -196,8 +203,10 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
}
// A write holds the world volume for its Job's lifetime (internal/maintenance);
// reads and listings do not, since a read-only mount cannot hurt a server
// starting beside it.
// reads and listings do not. A read-only mount cannot hurt a server starting
// beside it, and a read that overlaps a restore or another change can at worst
// show a file mid-change: the sha256 it returned then no longer matches, so a
// save built on it is refused with file_changed.
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
@@ -335,7 +344,10 @@ func (a *API) handleRenameFile(w http.ResponseWriter, r *http.Request) {
//
// Content-Length is required (411 length_required): the stage reserves room for
// the declared size before a byte is written, and a size promised up front is
// what lets a short body be told from a whole one.
// what lets a short body be told from a whole one. So is Content-Digest, the
// SHA-256 the client computed over the body (contentDigest): bytes that arrive
// hashing to anything else are refused with 400 digest_mismatch, and the client
// sends them again.
func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
@@ -345,6 +357,10 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if !namesFit(path) {
writeError(w, r, errNameTooLong())
return
}
if a.FileStage == nil || a.InternalBaseURL == "" {
writeError(w, r, newError(http.StatusServiceUnavailable, "files_unavailable",
"uploads are not configured"))
@@ -361,10 +377,17 @@ func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
r.ContentLength, fileedit.MaxUploadBytes))
return
}
want, ok := contentDigest(w, r)
if !ok {
return
}
overwrite := r.URL.Query().Get("overwrite") == "true"
staged, drop, err := a.FileStage.Put(r.Body, r.ContentLength)
staged, drop, err := a.FileStage.Put(r.Body, r.ContentLength, want)
switch {
case errors.Is(err, fileedit.ErrDigestMismatch):
writeError(w, r, errDigestMismatch())
return
case errors.Is(err, fileedit.ErrStageFull):
writeError(w, r, newError(http.StatusInsufficientStorage, "upload_staging_full",
"felis has no room to take this upload right now; try again later or ask an admin"))
@@ -429,11 +452,83 @@ func (a *API) handleInternalFileUpload(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusOK)
// A session sent whole is on the Job's side now; one cut short stays, so
// committing it again does not mean sending it again.
if n, err := io.Copy(w, f); err == nil && n == size {
a.FileStage.Served(r.PathValue("id"))
_, _ = io.Copy(w, f)
}
// handleInternalFileUploadLanded serves DELETE
// /api/v1/internal/file-uploads/{id} — the Job that fetched a file sent in
// parts reports it landed, with the token it fetched it by, and the staged copy
// is deleted. Until then the copy stays: a Job that failed after its fetch (the
// digest did not match, the volume filled, a file was in the way) is started
// again by committing again, without the file being sent again. Public on the
// internal face like the fetch, with the same token as the check; a token that
// does not open the upload, or an unknown id, is 404.
func (a *API) handleInternalFileUploadLanded(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if a.FileStage == nil || !ok || a.FileStage.Landed(r.PathValue("id"), token) != nil {
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such upload"))
return
}
w.WriteHeader(http.StatusNoContent)
}
// contentDigest reads the SHA-256 a client computed over the body it sends, from
// its Content-Digest header (parseContentDigest). An upload without one is
// refused (400 digest_required): felis-api could not otherwise tell bytes
// changed on the way from the bytes that were sent. It writes the refusal itself
// and reports false.
func contentDigest(w http.ResponseWriter, r *http.Request) ([]byte, bool) {
sum, err := parseContentDigest(r.Header.Values("Content-Digest"))
switch {
case errors.Is(err, errNoContentDigest):
writeError(w, r, newError(http.StatusBadRequest, "digest_required",
"send the SHA-256 of the body as Content-Digest: sha-256=:<base64>:"))
return nil, false
case err != nil:
writeError(w, r, newError(http.StatusBadRequest, "bad_digest",
"Content-Digest must carry sha-256=:<base64 of the 32-byte SHA-256>:"))
return nil, false
}
return sum, true
}
var (
errNoContentDigest = errors.New("no sha-256 Content-Digest")
errBadContentDigest = errors.New("a malformed sha-256 Content-Digest")
)
// parseContentDigest finds the SHA-256 in the values of a Content-Digest field
// (RFC 9530): sha-256=:<base64>:, among any other algorithms it lists, which
// are ignored. errNoContentDigest when there is none, errBadContentDigest when
// it is not 32 bytes of base64 between colons.
func parseContentDigest(values []string) ([]byte, error) {
var found []byte
for _, v := range values {
for _, member := range strings.Split(v, ",") {
key, value, _ := strings.Cut(member, "=")
if !strings.EqualFold(strings.TrimSpace(key), "sha-256") {
continue
}
value, _, _ = strings.Cut(value, ";") // parameters
value = strings.TrimSpace(value)
inner, pre := strings.CutPrefix(value, ":")
inner, post := strings.CutSuffix(inner, ":")
sum, err := base64.StdEncoding.DecodeString(inner)
if !pre || !post || err != nil || len(sum) != sha256.Size {
return nil, errBadContentDigest
}
found = sum
}
}
if found == nil {
return nil, errNoContentDigest
}
return found, nil
}
func errDigestMismatch() error {
return newError(http.StatusBadRequest, "digest_mismatch",
"the bytes that arrived do not match their Content-Digest, so they were changed on the way; send them again")
}
// auditFile records a file change. The target is "<server>:<path>", as file.write
+111 -10
View File
@@ -3,6 +3,7 @@ package api
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
@@ -125,12 +126,12 @@ func (f *fakeFileEditor) Ops(_ context.Context, server string) ([]fileedit.OpSta
return f.ops, f.err
}
// fileRouteHeader is the Content-Type a file route's body goes with: raw bytes
// for an upload, JSON for any other body.
// fileRouteHeader is the headers a file route's body goes with: raw bytes and
// their Content-Digest for an upload, JSON for any other body.
func fileRouteHeader(name, body string) map[string]string {
switch {
case name == "upload":
return ctHeader("application/octet-stream")
return map[string]string{"Content-Type": "application/octet-stream", "Content-Digest": contentDigestOf(body)}
case body != "":
return jsonHeader
}
@@ -397,6 +398,21 @@ func TestFileEditorHandlers(t *testing.T) {
}
})
t.Run("a full volume lists 0 free and one the Job could not measure null", func(t *testing.T) {
for _, c := range []struct {
free int64
want string
}{{0, `"free_bytes":0`}, {-1, `"free_bytes":null`}} {
api, _, _, files := mkFiles(t)
files.free = c.free
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files?path=config", "", nil)
if w.Code != http.StatusOK || !strings.Contains(w.Body.String(), c.want) {
t.Fatalf("free %d: code = %d body %s, want %s", c.free, w.Code, w.Body.String(), c.want)
}
}
})
t.Run("list without a path lists the world root", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
@@ -744,11 +760,19 @@ func TestFileManagerHandlers(t *testing.T) {
})
}
// contentDigestOf is the Content-Digest a client sends with body.
func contentDigestOf(body string) string {
sum := sha256.Sum256([]byte(body))
return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
}
// doUpload sends an upload whose Content-Length is declared, not measured, the
// way a client that streams or lies would send it.
// way a client that streams or lies would send it. Its Content-Digest is that
// of the body.
func doUpload(h http.Handler, body string, length int64) *httptest.ResponseRecorder {
r := httptest.NewRequest("PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", strings.NewReader(body))
r.Header.Set("Content-Type", "application/octet-stream")
r.Header.Set("Content-Digest", contentDigestOf(body))
r.ContentLength = length
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
@@ -777,7 +801,7 @@ func TestFileUpload(t *testing.T) {
digest := sha256.Sum256([]byte(body))
sum := hex.EncodeToString(digest[:])
const route = "/api/v1/servers/survival/files/upload?path=plugins/x.jar"
octet := ctHeader("application/octet-stream")
octet := map[string]string{"Content-Type": "application/octet-stream", "Content-Digest": contentDigestOf(body)}
t.Run("the Job fetches the body once, with its token alone", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
@@ -785,7 +809,7 @@ func TestFileUpload(t *testing.T) {
// Every other internal route wants a service token; the Job has none.
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
prefix := api.InternalBaseURL + "/api/v1/internal/file-uploads/"
var bare, wrong, unschemed, fetched, again *httptest.ResponseRecorder
var bare, wrong, unschemed, fetched, again, landedWrong, landed *httptest.ResponseRecorder
files.onUpload = func(src fileedit.UploadSource) {
id, ok := strings.CutPrefix(src.URL, prefix)
if !ok || len(id) != 32 {
@@ -799,6 +823,10 @@ func TestFileUpload(t *testing.T) {
unschemed = do(h, "GET", at, "", map[string]string{"Authorization": src.Token})
fetched = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
again = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
// The Job reports it landed. A single upload goes when its request
// ends, so the report takes nothing away early.
landedWrong = do(h, "DELETE", at, "", map[string]string{"Authorization": "Bearer " + strings.Repeat("0", len(src.Token))})
landed = do(h, "DELETE", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
}
w := do(api.ExternalHandler(), "PUT", route, body, octet)
@@ -810,6 +838,7 @@ func TestFileUpload(t *testing.T) {
}
for name, r := range map[string]*httptest.ResponseRecorder{
"no token": bare, "wrong token": wrong, "the token without Bearer": unschemed, "second fetch": again,
"a landed report with the wrong token": landedWrong,
} {
if r.Code != http.StatusNotFound || decodeErr(t, r) != "not_found" {
t.Errorf("%s: code = %d (%s), want 404 not_found", name, r.Code, r.Body.String())
@@ -819,6 +848,9 @@ func TestFileUpload(t *testing.T) {
fetched.Header().Get("Content-Length") != strconv.Itoa(len(body)) {
t.Fatalf("fetch: code = %d, %q, Content-Length %q", fetched.Code, fetched.Body.String(), fetched.Header().Get("Content-Length"))
}
if landed.Code != http.StatusNoContent {
t.Fatalf("landed: code = %d (%s)", landed.Code, landed.Body.String())
}
if files.gotPath != "plugins/x.jar" || files.gotSource.Size != int64(len(body)) ||
files.gotSource.SHA256 != sum || files.gotOverwrite {
t.Fatalf("executor saw path %q, source %+v, overwrite %v", files.gotPath, files.gotSource, files.gotOverwrite)
@@ -868,6 +900,51 @@ func TestFileUpload(t *testing.T) {
stageEmpty(t, api)
})
t.Run("the body comes with its SHA-256, checked before anything is asked of the world", func(t *testing.T) {
other := sha256.Sum256([]byte("PK\x03\x04 another jar"))
for _, c := range []struct {
name string
digest []string
code int
errCode string
}{
{"none", nil, http.StatusBadRequest, "digest_required"},
{"only another algorithm", []string{"sha-512=:" + base64.StdEncoding.EncodeToString(make([]byte, 64)) + ":"}, http.StatusBadRequest, "digest_required"},
{"hex in place of base64", []string{"sha-256=:" + sum + ":"}, http.StatusBadRequest, "bad_digest"},
{"a bare base64 value", []string{strings.Trim(strings.TrimPrefix(contentDigestOf(body), "sha-256="), ":")}, http.StatusBadRequest, "digest_required"},
{"no closing colon", []string{strings.TrimSuffix(contentDigestOf(body), ":")}, http.StatusBadRequest, "bad_digest"},
{"base64 without colons", []string{"sha-256=" + strings.Trim(strings.TrimPrefix(contentDigestOf(body), "sha-256="), ":")}, http.StatusBadRequest, "bad_digest"},
{"31 bytes", []string{"sha-256=:" + base64.StdEncoding.EncodeToString(digest[:31]) + ":"}, http.StatusBadRequest, "bad_digest"},
{"another body's", []string{"sha-256=:" + base64.StdEncoding.EncodeToString(other[:]) + ":"}, http.StatusBadRequest, "digest_mismatch"},
{"the right one, then a wrong one", []string{contentDigestOf(body), "sha-256=:" + base64.StdEncoding.EncodeToString(other[:]) + ":"}, http.StatusBadRequest, "digest_mismatch"},
{"among others, upper case, with a parameter", []string{"sha-512=:" + base64.StdEncoding.EncodeToString(make([]byte, 64)) + ":, SHA-256=" + strings.TrimPrefix(contentDigestOf(body), "sha-256=") + ";p=1"}, http.StatusOK, ""},
} {
t.Run(c.name, func(t *testing.T) {
api, repo, cl, files := mkFiles(t)
api.External = staticExternal{p: owner}
r := httptest.NewRequest("PUT", route, strings.NewReader(body))
r.Header.Set("Content-Type", "application/octet-stream")
for _, v := range c.digest {
r.Header.Add("Content-Digest", v)
}
w := httptest.NewRecorder()
api.ExternalHandler().ServeHTTP(w, r)
recordContract(r, body, w)
if c.code == http.StatusOK {
if w.Code != http.StatusOK || files.calls != 1 || files.gotSource.SHA256 != sum {
t.Fatalf("code = %d calls = %d source %+v (%s)", w.Code, files.calls, files.gotSource, w.Body.String())
}
return
}
if w.Code != c.code || decodeErr(t, w) != c.errCode || files.calls != 0 || len(cl.acquired) != 0 || len(repo.audits) != 0 {
t.Fatalf("code = %d calls = %d acquired %v audits %d (%s), want %d %s",
w.Code, files.calls, cl.acquired, len(repo.audits), w.Body.String(), c.code, c.errCode)
}
stageEmpty(t, api)
})
}
})
t.Run("no Content-Length -> 411", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
@@ -887,6 +964,28 @@ func TestFileUpload(t *testing.T) {
stageEmpty(t, api)
})
t.Run("a name longer than a folder entry holds -> 400 before a byte is staged", func(t *testing.T) {
for _, c := range []struct {
name string
code int
errCode string
}{
{strings.Repeat("n", fileedit.NameMax-3) + ".jar", http.StatusBadRequest, "bad_path"},
{strings.Repeat("n", fileedit.NameMax-4) + ".jar", http.StatusOK, ""},
} {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/files/upload?path=plugins/"+c.name, body, octet)
if w.Code != c.code || (c.errCode != "" && decodeErr(t, w) != c.errCode) {
t.Fatalf("%d-byte name: code = %d (%s), want %d", len(c.name), w.Code, w.Body.String(), c.code)
}
if wantCalls := map[bool]int{true: 1, false: 0}[c.code == http.StatusOK]; files.calls != wantCalls {
t.Fatalf("%d-byte name: executor calls = %d, want %d", len(c.name), files.calls, wantCalls)
}
stageEmpty(t, api)
}
})
// Staged, and so short: the body is a few bytes of a declared 64 MiB.
t.Run("a declared size at the cap is taken", func(t *testing.T) {
api, _, _, files := mkFiles(t)
@@ -935,10 +1034,12 @@ func TestFileUpload(t *testing.T) {
t.Run("the internal route without a stage -> 404", func(t *testing.T) {
api, _, _, _ := mkFiles(t)
api.FileStage = nil
w := do(api.InternalHandler(), "GET", "/api/v1/internal/file-uploads/00112233445566778899aabbccddeeff", "",
map[string]string{"Authorization": "Bearer t"})
if w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
for _, method := range []string{"GET", "DELETE"} {
w := do(api.InternalHandler(), method, "/api/v1/internal/file-uploads/00112233445566778899aabbccddeeff", "",
map[string]string{"Authorization": "Bearer t"})
if w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
t.Fatalf("%s: code = %d (%s)", method, w.Code, w.Body.String())
}
}
})
}
+7
View File
@@ -179,6 +179,9 @@ func TestLatestJobsExplainsFailures(t *testing.T) {
}}},
}
}
// Killed for memory mid-walk: the last line it printed says nothing of that.
oom := pod("c-1", "backup-survival-c", 4, 137, "archiving survival\n")
oom.Status.ContainerStatuses[0].State.Terminated.Reason = "OOMKilled"
c := fake.NewClientBuilder().WithScheme(scheme).WithObjects(
failedJob("backup-survival-a", 1),
failedJob("backup-survival-b", 2),
@@ -186,6 +189,7 @@ func TestLatestJobsExplainsFailures(t *testing.T) {
pod("a-2", "backup-survival-a", 3, 1,
"archiving survival\nfelis backup: backup: not enough free disk for the archive: the world is 2.0 GiB\n"),
pod("b-1", "backup-survival-b", 2, 0, "done"),
failedJob("backup-survival-c", 4), oom,
).Build()
jobs, err := NewK8sJobStatus(c, "minecraft").LatestJobs(context.Background(), "survival")
@@ -202,4 +206,7 @@ func TestLatestJobsExplainsFailures(t *testing.T) {
if want := "Job has reached the specified backoff limit"; got["backup-survival-b"] != want {
t.Errorf("b: message = %q, want the condition text", got["backup-survival-b"])
}
if want := "the job ran out of memory and the system stopped it (OOMKilled)"; got["backup-survival-c"] != want {
t.Errorf("c: message = %q, want %q", got["backup-survival-c"], want)
}
}
+11 -1
View File
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
batchv1 "k8s.io/api/batch/v1"
corev1 "k8s.io/api/core/v1"
@@ -107,14 +108,23 @@ func (k *K8sJobStatus) explainFailures(ctx context.Context, serverName string, j
}
}
// outOfMemoryLine is a failed Job's message when the kernel killed it for
// memory. The reason in brackets is what the panel knows it by.
const outOfMemoryLine = "the job ran out of memory and the system stopped it (OOMKilled)"
// lastTerminationLine returns the last non-empty line of the pod's terminated
// container message, capped for display.
// container message, capped for display. A container the kernel killed for
// going over its memory limit printed nothing about it, so that is said instead
// of whatever line it happened to print last.
func lastTerminationLine(pod *corev1.Pod) string {
for _, cs := range pod.Status.ContainerStatuses {
t := cs.State.Terminated
if t == nil || t.ExitCode == 0 {
continue
}
if t.Reason == fileedit.ReasonOOMKilled {
return outOfMemoryLine
}
lines := strings.Split(strings.TrimSpace(t.Message), "\n")
line := strings.TrimSpace(lines[len(lines)-1])
if len(line) > 400 {
+1 -1
View File
@@ -38,7 +38,7 @@ func maintenanceLabel(kind string) string {
case maintenance.KindFileWrite:
return "a file write"
case maintenance.KindExport:
return "a world export"
return "a world export or file download"
case maintenance.KindReap:
return "the idle-world reaper"
}
+18 -3
View File
@@ -166,6 +166,10 @@ func (a *API) handleCreateSubmission(w http.ResponseWriter, r *http.Request) {
// caller does not own is reported as 404, so this endpoint cannot upload to — or
// probe the existence of — another user's submission.
//
// The body carries its SHA-256 as Content-Digest (contentDigest); bytes that hash
// to anything else were changed on the way, and none of them are kept
// (submit.VerifyDigest).
//
// Uploading does not change the submission row (there is no "uploaded" column):
// the blob store is the presence source of truth, which admin approval consults.
func (a *API) handleUploadSubmissionContext(w http.ResponseWriter, r *http.Request) {
@@ -173,6 +177,10 @@ func (a *API) handleUploadSubmissionContext(w http.ResponseWriter, r *http.Reque
writeError(w, r, errSubmissionsUnavailable)
return
}
want, ok := contentDigest(w, r)
if !ok {
return
}
p := principalFromContext(r.Context())
// Reserve the per-user upload cooldown BEFORE streaming. The body is the
// expensive part (up to the 1 GiB blob cap), so without a reservation the
@@ -188,7 +196,7 @@ func (a *API) handleUploadSubmissionContext(w http.ResponseWriter, r *http.Reque
}
defer release()
id := r.PathValue("id")
sub, err := a.Submissions.UploadContext(r.Context(), id, p.UserID, r.Body)
sub, err := a.Submissions.UploadContext(r.Context(), id, p.UserID, submit.VerifyDigest(r.Body, want))
if err != nil {
writeSubmitError(w, r, err)
return
@@ -240,7 +248,8 @@ func (a *API) handleContextUploadStatus(w http.ResponseWriter, r *http.Request)
// else must equal the staged length (409 upload_offset_mismatch otherwise). A
// part is small enough for any edge, so no cooldown applies here: the staged
// total is bounded by the context cap and the storage budget, and completion
// holds the cooldown.
// holds the cooldown. Each part carries its own Content-Digest, and one that
// does not hash to it is cut back off, as a part that breaks off is.
func (a *API) handleContextUploadPart(w http.ResponseWriter, r *http.Request) {
if a.Submissions == nil {
writeError(w, r, errSubmissionsUnavailable)
@@ -252,8 +261,12 @@ func (a *API) handleContextUploadPart(w http.ResponseWriter, r *http.Request) {
"offset must be the byte position the part starts at"))
return
}
want, ok := contentDigest(w, r)
if !ok {
return
}
p := principalFromContext(r.Context())
prog, err := a.Submissions.UploadPart(r.Context(), r.PathValue("id"), p.UserID, offset, r.Body)
prog, err := a.Submissions.UploadPart(r.Context(), r.PathValue("id"), p.UserID, offset, submit.VerifyDigest(r.Body, want))
if err != nil {
writeSubmitError(w, r, err)
return
@@ -557,6 +570,8 @@ func writeSubmitError(w http.ResponseWriter, r *http.Request, err error) {
case errors.Is(err, submit.ErrUploadBusy):
writeError(w, r, newError(http.StatusConflict, "upload_busy",
"another request is still writing this upload; read where it stands and continue from there"))
case errors.Is(err, submit.ErrDigestMismatch):
writeError(w, r, errDigestMismatch())
case errors.As(err, &mismatch):
writeError(w, r, newError(http.StatusConflict, "upload_offset_mismatch",
"the upload holds %d bytes; send the part that starts there", mismatch.Received))
+44 -3
View File
@@ -14,7 +14,7 @@ func TestContextUploadPartForwardsOffsetBodyAndPrincipal(t *testing.T) {
fs := &fakeSubmissions{progress: submit.UploadProgress{Received: 8, PartMaxBytes: 33554432, MaxContextBytes: 1073741824}}
api := appSubAPI(fs)
w := do(api.ExternalHandler(), "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=4", "abcd",
ctHeader("application/octet-stream"))
map[string]string{"Content-Type": "application/octet-stream", "Content-Digest": contentDigestOf("abcd")})
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
@@ -72,7 +72,8 @@ func TestContextUploadErrors(t *testing.T) {
503, "uploads_store_unavailable", "send the request again", "5"},
} {
fs := &fakeSubmissions{chunkErr: tc.err}
w := do(appSubAPI(fs).ExternalHandler(), "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=12", "abcd", nil)
w := do(appSubAPI(fs).ExternalHandler(), "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=12", "abcd",
map[string]string{"Content-Digest": contentDigestOf("abcd")})
if w.Code != tc.code || decodeErr(t, w) != tc.want {
t.Errorf("%s: %d %s, want %d %s", tc.name, w.Code, w.Body.String(), tc.code, tc.want)
}
@@ -122,7 +123,8 @@ func TestContextUploadCompleteHoldsTheCooldownAndAudits(t *testing.T) {
t.Fatalf("second completion in the window: %d %s, want 429 submission_cooldown", w.Code, w.Body.String())
}
// A part never waits on the cooldown.
if w := do(eh, "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=0", "\x1f\x8b", nil); w.Code != http.StatusOK {
if w := do(eh, "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=0", "\x1f\x8b",
map[string]string{"Content-Digest": contentDigestOf("\x1f\x8b")}); w.Code != http.StatusOK {
t.Fatalf("part inside the cooldown: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
}
@@ -141,3 +143,42 @@ func TestContextUploadWithoutServiceIs503(t *testing.T) {
}
}
}
// A context upload, whole or in parts, carries the SHA-256 of its body: one
// without it is refused before the lane sees it, and bytes that do not hash to
// it are refused as changed on the way (the lane keeps none of them), so the
// client sends them again.
func TestContextUploadsCheckTheBodyDigest(t *testing.T) {
body := "\x1f\x8b\x08\x00 the modpack bytes"
for _, rq := range []struct{ name, method, target string }{
{"a part", "PUT", "/api/v1/me/submissions/sub-9/context/upload?offset=0"},
{"a whole context", "POST", "/api/v1/me/submissions/sub-9/context"},
} {
for _, tc := range []struct {
name string
sent string
headers map[string]string
code int
want string
}{
{"without a digest", body, nil, http.StatusBadRequest, "digest_required"},
{"changed on the way", body[:len(body)-1] + "X", map[string]string{"Content-Digest": contentDigestOf(body)}, http.StatusBadRequest, "digest_mismatch"},
{"as sent", body, map[string]string{"Content-Digest": contentDigestOf(body)}, http.StatusOK, ""},
} {
t.Run(rq.name+" "+tc.name, func(t *testing.T) {
fs := &fakeSubmissions{}
w := do(appSubAPI(fs).ExternalHandler(), rq.method, rq.target, tc.sent, tc.headers)
if w.Code != tc.code {
t.Fatalf("code = %d (%s), want %d", w.Code, w.Body.String(), tc.code)
}
if tc.want != "" && decodeErr(t, w) != tc.want {
t.Fatalf("error = %s, want %s", w.Body.String(), tc.want)
}
reached := fs.chunkID != "" || fs.uploadedID != ""
if reached != (tc.headers != nil) {
t.Fatalf("the body reached the lane: %v, want %v", reached, tc.headers != nil)
}
})
}
}
}
+22 -13
View File
@@ -73,8 +73,13 @@ func (f *fakeSubmissions) UploadStatus(_ context.Context, id, submittedBy string
func (f *fakeSubmissions) UploadPart(_ context.Context, id, submittedBy string, offset int64, r io.Reader) (submit.UploadProgress, error) {
f.chunkID, f.chunkBy, f.partOffset = id, submittedBy, offset
b, _ := io.ReadAll(r)
b, err := io.ReadAll(r)
f.partBody = string(b)
if err != nil {
// A read that fails (a body changed on the way) keeps nothing, as in
// PartStore.
return submit.UploadProgress{}, err
}
return f.progress, f.chunkErr
}
@@ -101,7 +106,10 @@ func (f *fakeSubmissions) UploadContext(_ context.Context, id, submittedBy strin
if f.uploadErr != nil {
return nil, f.uploadErr
}
n, _ := io.Copy(io.Discard, r)
n, err := io.Copy(io.Discard, r)
if err != nil {
return nil, err
}
f.uploadedN = n
return &submit.Submission{ID: id, SubmittedBy: submittedBy, Status: submit.StatusPendingReview}, nil
}
@@ -235,7 +243,7 @@ func TestUploadSubmissionContextStreamsBody(t *testing.T) {
// A tiny gzip-magic-prefixed body stands in for a real context.tar.gz.
body := "\x1f\x8b\x08\x00 the modpack bytes"
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", body,
map[string]string{"Content-Type": "application/gzip"})
map[string]string{"Content-Type": "application/gzip", "Content-Digest": contentDigestOf(body)})
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
@@ -255,7 +263,7 @@ func TestUploadSubmissionContextStreamsBody(t *testing.T) {
func TestUploadSubmissionContextNotOwnedIs404(t *testing.T) {
fs := &fakeSubmissions{uploadErr: submit.ErrNotFound}
api := appSubAPI(fs)
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-x/context", "\x1f\x8bdata", nil)
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-x/context", "\x1f\x8bdata", map[string]string{"Content-Digest": contentDigestOf("\x1f\x8bdata")})
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
}
@@ -265,7 +273,7 @@ func TestUploadSubmissionContextNotOwnedIs404(t *testing.T) {
func TestUploadSubmissionContextAlreadyReviewedIs409(t *testing.T) {
fs := &fakeSubmissions{uploadErr: submit.ErrAlreadyReviewed}
api := appSubAPI(fs)
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", map[string]string{"Content-Digest": contentDigestOf("\x1f\x8bdata")})
if w.Code != http.StatusConflict {
t.Fatalf("code = %d, want 409 (%s)", w.Code, w.Body.String())
}
@@ -275,7 +283,7 @@ func TestUploadSubmissionContextAlreadyReviewedIs409(t *testing.T) {
func TestUploadSubmissionContextBadFormatIs400(t *testing.T) {
fs := &fakeSubmissions{uploadErr: fmt.Errorf("%w: build context must be a gzip-compressed tarball (.tar.gz)", submit.ErrInvalid)}
api := appSubAPI(fs)
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "not gzip", nil)
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "not gzip", map[string]string{"Content-Digest": contentDigestOf("not gzip")})
if w.Code != http.StatusBadRequest {
t.Fatalf("code = %d, want 400 (%s)", w.Code, w.Body.String())
}
@@ -289,7 +297,7 @@ func TestUploadSubmissionContextBadFormatIs400(t *testing.T) {
func TestUploadSubmissionContextNoTransportIs503(t *testing.T) {
fs := &fakeSubmissions{uploadErr: submit.ErrUploadsUnavailable}
api := appSubAPI(fs)
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
w := do(api.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", map[string]string{"Content-Digest": contentDigestOf("\x1f\x8bdata")})
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
}
@@ -303,7 +311,7 @@ func TestUploadSubmissionContextNoTransportIs503(t *testing.T) {
func TestUploadSubmissionContextWithoutServiceIs503(t *testing.T) {
app := appSubAPI(nil)
app.Submissions = nil
w := do(app.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
w := do(app.ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", map[string]string{"Content-Digest": contentDigestOf("\x1f\x8bdata")})
if w.Code != http.StatusServiceUnavailable {
t.Fatalf("code = %d, want 503 (%s)", w.Code, w.Body.String())
}
@@ -871,7 +879,7 @@ func TestSubmissionQuotaIs403(t *testing.T) {
})
t.Run("upload", func(t *testing.T) {
fs := &fakeSubmissions{uploadErr: fmt.Errorf("%w: exceeds your remaining storage allowance", submit.ErrQuotaExceeded)}
w := do(appSubAPI(fs).ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", nil)
w := do(appSubAPI(fs).ExternalHandler(), "POST", "/api/v1/me/submissions/sub-9/context", "\x1f\x8bdata", map[string]string{"Content-Digest": contentDigestOf("\x1f\x8bdata")})
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
@@ -939,11 +947,12 @@ func TestUploadSubmissionContextRateLimited(t *testing.T) {
api.SubmitUploadCooldown = time.Minute
eh := api.ExternalHandler()
body := "\x1f\x8b\x08\x00 the modpack bytes"
sent := map[string]string{"Content-Digest": contentDigestOf(body)}
if w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context", body, nil); w.Code != http.StatusOK {
if w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context", body, sent); w.Code != http.StatusOK {
t.Fatalf("first upload: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context", body, nil)
w := do(eh, "POST", "/api/v1/me/submissions/sub-9/context", body, sent)
if w.Code != http.StatusTooManyRequests || decodeErr(t, w) != "submission_cooldown" {
t.Fatalf("immediate second upload: code = %d body %s, want 429 submission_cooldown", w.Code, w.Body.String())
}
@@ -955,11 +964,11 @@ func TestUploadSubmissionContextRateLimited(t *testing.T) {
api2.Now = func() time.Time { return clock }
api2.SubmitUploadCooldown = time.Minute
eh2 := api2.ExternalHandler()
if w := do(eh2, "POST", "/api/v1/me/submissions/sub-9/context", body, nil); w.Code != http.StatusServiceUnavailable {
if w := do(eh2, "POST", "/api/v1/me/submissions/sub-9/context", body, sent); w.Code != http.StatusServiceUnavailable {
t.Fatalf("failed upload: code = %d, want 503", w.Code)
}
fs2.uploadErr = nil
if w := do(eh2, "POST", "/api/v1/me/submissions/sub-9/context", body, nil); w.Code != http.StatusOK {
if w := do(eh2, "POST", "/api/v1/me/submissions/sub-9/context", body, sent); w.Code != http.StatusOK {
t.Fatalf("retry at the same instant after failure: code = %d, want 200 (%s)", w.Code, w.Body.String())
}
}