From 40dd9bb68f011ebec23c4e0e62e7256018d08395 Mon Sep 17 00:00:00 2001 From: zarzet Date: Sun, 26 Jul 2026 01:28:09 +0700 Subject: [PATCH] fix(download): avoid unsafe automatic range resume --- go_backend/extension_runtime_file.go | 20 ++++++---- .../extension_runtime_supplement_test.go | 39 ++++++++++++++++++- 2 files changed, 51 insertions(+), 8 deletions(-) diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index da0d9ad8..baf556f3 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -129,6 +129,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { var onProgress goja.Callable var headers map[string]string var chunkedDownload bool + var resumeDownload bool trackItemBytes := true var chunkSize int64 if len(call.Arguments) > 2 && !goja.IsUndefined(call.Arguments[2]) && !goja.IsNull(call.Arguments[2]) { @@ -170,6 +171,11 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } } } + if resume, ok := opts["resume"]; ok { + if v, ok := resume.(bool); ok { + resumeDownload = v + } + } } } @@ -328,14 +334,14 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } } - // Mid-body network failures resume from the current offset instead of - // failing the whole download. Only attempted when the server gave a - // validator (If-Range guards against splicing two versions of the file) - // and the caller did not set its own Range. A server that ignores Range - // answers 200 and the download restarts from zero — same as today. + // Mid-body resume is opt-in because switching networks can route a stable + // URL to a different CDN object even when its validator is unchanged. The + // safe default is to fail and delete the staged partial file. Extensions + // that know their origin supports byte-identical Range resumes can request + // it explicitly with { resume: true }. validator := resumeValidator(resp.Header) _, callerSetRange := headers["Range"] - canResume := validator != "" && !callerSetRange + canResume := resumeDownload && validator != "" && !callerSetRange const maxResumes = 3 resumes := 0 @@ -401,7 +407,7 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } } validator = resumeValidator(resp.Header) - canResume = validator != "" + canResume = resumeDownload && validator != "" default: code := resp.StatusCode resp.Body.Close() diff --git a/go_backend/extension_runtime_supplement_test.go b/go_backend/extension_runtime_supplement_test.go index 61faf852..d87878ab 100644 --- a/go_backend/extension_runtime_supplement_test.go +++ b/go_backend/extension_runtime_supplement_test.go @@ -267,7 +267,42 @@ func TestFileDownloadFailureLeavesNoFinalFile(t *testing.T) { } } -func TestFileDownloadResumesAfterMidBodyCut(t *testing.T) { +func TestFileDownloadDoesNotResumeMidBodyCutByDefault(t *testing.T) { + const full = "hello-world!" + var attempts int + runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) { + attempts++ + h := make(http.Header) + h.Set("ETag", `"v1"`) + return &http.Response{ + StatusCode: 200, + Header: h, + Body: io.NopCloser(&failingBodyReader{data: []byte(full[:6])}), + ContentLength: int64(len(full)), + Request: req, + }, nil + }) + + result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{ + runtime.vm.ToValue("https://cdn.example.com/track.flac"), + runtime.vm.ToValue("out/track.flac"), + }}).Export().(map[string]any) + if result["success"] != false { + t.Fatalf("expected failed download, got %#v", result) + } + if attempts != 1 { + t.Fatalf("attempts = %d, want no automatic resume", attempts) + } + finalPath := filepath.Join(runtime.dataDir, "out", "track.flac") + if _, err := os.Stat(finalPath); !os.IsNotExist(err) { + t.Fatalf("partial download visible at final path: %v", err) + } + if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) { + t.Fatalf("staged file left behind: %v", err) + } +} + +func TestFileDownloadResumesAfterMidBodyCutWhenEnabled(t *testing.T) { const full = "hello-world!" var attempts int var resumeRange, resumeIfRange string @@ -300,6 +335,7 @@ func TestFileDownloadResumesAfterMidBodyCut(t *testing.T) { result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{ runtime.vm.ToValue("https://cdn.example.com/track.flac"), runtime.vm.ToValue("out/track.flac"), + runtime.vm.ToValue(map[string]any{"resume": true}), }}).Export().(map[string]any) if result["success"] != true { t.Fatalf("download result = %#v", result) @@ -342,6 +378,7 @@ func TestFileDownloadResumeRestartsWhenRangeIgnored(t *testing.T) { result := runtime.fileDownload(goja.FunctionCall{Arguments: []goja.Value{ runtime.vm.ToValue("https://cdn.example.com/track.flac"), runtime.vm.ToValue("out/track.flac"), + runtime.vm.ToValue(map[string]any{"resume": true}), }}).Export().(map[string]any) if result["success"] != true { t.Fatalf("download result = %#v", result)