From 48dbe3679d080dab2d9ee8314bf954bd7187c4b0 Mon Sep 17 00:00:00 2001 From: Abelardo Ramirez Date: Thu, 30 Jul 2026 11:31:20 -0600 Subject: [PATCH] fix(download): treat a short clean EOF as resumable, not complete Follow-up to #491, which was fixed in 4.8.5 by making mid-download resume opt-in (safe default: fail and delete the staged file, since switching networks can route a stable URL to a different CDN object). That fix only changes what happens on a *real* read error. It doesn't help if a transport surfaces a mid-transfer connection drop as a plain io.EOF instead of io.ErrUnexpectedEOF - fileDownload's copyBody loop still breaks out and promotes the file on any clean EOF, regardless of whether written bytes actually reached Content-Length. This app's uTLS-based client (used for TLS-fingerprint spoofing) is exactly the kind of custom transport where that guarantee isn't necessarily upheld. Now a clean EOF short of a known Content-Length is routed through the same resume-or-fail path as a real read error, so it respects the same opt-in `resume` option: fails and cleans up the staged file by default, or resumes via Range/If-Range when the caller explicitly requested it and the server provided a validator. extension_runtime_file_download_integrity_test.go adds three tests: - fails by default even when a validator is present (matches the opt-in policy from 4.8.5) - resumes correctly when `resume: true` is passed - fails and cleans up when there's no validator at all All three fail on the pre-fix code and pass with it. Note: fileDownloadChunked's unknown-total-size path (used for YouTube's CDN) has an analogous but harder-to-fix ambiguity - without a known length there's no way to distinguish a legitimately short final chunk from a truncated one - left as a follow-up. --- go_backend/extension_runtime_file.go | 11 +- ...on_runtime_file_download_integrity_test.go | 149 ++++++++++++++++++ 2 files changed, 159 insertions(+), 1 deletion(-) create mode 100644 go_backend/extension_runtime_file_download_integrity_test.go diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 2efb1f57..8c6de5ce 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -356,7 +356,16 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { return fatal } if readErr == nil { - break + if contentLength <= 0 || written == contentLength { + break + } + // The body reported a clean EOF (e.g. a mid-transfer connection + // reset that the transport surfaced as normal end-of-stream + // instead of io.ErrUnexpectedEOF) but fewer bytes arrived than + // the server promised in Content-Length. Route it through the + // same resume-or-fail path as a real read error instead of + // silently promoting a truncated file. + readErr = io.ErrUnexpectedEOF } stalled := wd.stalled.Load() diff --git a/go_backend/extension_runtime_file_download_integrity_test.go b/go_backend/extension_runtime_file_download_integrity_test.go new file mode 100644 index 00000000..1bfd07bb --- /dev/null +++ b/go_backend/extension_runtime_file_download_integrity_test.go @@ -0,0 +1,149 @@ +package gobackend + +import ( + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/dop251/goja" +) + +// shortCleanEOFBodyReader simulates a transport that surfaces a mid-transfer +// connection drop as a normal io.EOF instead of io.ErrUnexpectedEOF (which is +// what a network reset looks like through some custom transports, e.g. the +// uTLS-based client this app uses for TLS-fingerprint spoofing). It sends +// data once and then reports a clean end of stream, even though fewer bytes +// were sent than the response's Content-Length promised. +type shortCleanEOFBodyReader struct { + data []byte + sent bool +} + +func (f *shortCleanEOFBodyReader) Read(p []byte) (int, error) { + if !f.sent { + f.sent = true + n := copy(p, f.data) + return n, io.EOF + } + return 0, io.EOF +} + +func TestFileDownloadShortCleanEOFFailsByDefaultEvenWithValidator(t *testing.T) { + 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(&shortCleanEOFBodyReader{data: []byte("hello-")}), + ContentLength: int64(len("hello-world!")), + Request: req, + }, nil + }) + + // Resume is opt-in (see fileDownload's canResume comment), so a short + // clean EOF must fail and clean up just like a real read error would, + // not silently resume just because a validator happens to be present. + 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("truncated download was promoted to the final path: %v", err) + } + if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) { + t.Fatalf("staged partial file left behind: %v", err) + } +} + +func TestFileDownloadResumesAfterShortCleanEOFWhenEnabled(t *testing.T) { + const full = "hello-world!" + var attempts int + var resumeRange, resumeIfRange string + runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) { + attempts++ + if attempts == 1 { + h := make(http.Header) + h.Set("ETag", `"v1"`) + return &http.Response{ + StatusCode: 200, + Header: h, + Body: io.NopCloser(&shortCleanEOFBodyReader{data: []byte(full[:6])}), + ContentLength: int64(len(full)), + Request: req, + }, nil + } + resumeRange = req.Header.Get("Range") + resumeIfRange = req.Header.Get("If-Range") + h := make(http.Header) + h.Set("Content-Range", fmt.Sprintf("bytes 6-%d/%d", len(full)-1, len(full))) + return &http.Response{ + StatusCode: 206, + Header: h, + Body: io.NopCloser(&shortCleanEOFBodyReader{data: []byte(full[6:])}), + ContentLength: int64(len(full) - 6), + 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"), + runtime.vm.ToValue(map[string]any{"resume": true}), + }}).Export().(map[string]any) + if result["success"] != true { + t.Fatalf("download result = %#v", result) + } + if attempts != 2 || resumeRange != "bytes=6-" || resumeIfRange != `"v1"` { + t.Fatalf("attempts=%d range=%q if-range=%q", attempts, resumeRange, resumeIfRange) + } + + finalPath := filepath.Join(runtime.dataDir, "out", "track.flac") + data, err := os.ReadFile(finalPath) + if err != nil || string(data) != full { + t.Fatalf("final file = %q/%v (a truncated file was silently promoted)", data, err) + } +} + +func TestFileDownloadShortCleanEOFWithoutValidatorFails(t *testing.T) { + runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) { + // No ETag/Last-Modified, so the download cannot be resumed and must + // fail outright rather than promote a truncated file. + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(&shortCleanEOFBodyReader{data: []byte("partial-aud")}), + ContentLength: 1 << 20, + 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 a failed download for a short clean EOF with no validator, got %#v", result) + } + + finalPath := filepath.Join(runtime.dataDir, "out", "track.flac") + if _, err := os.Stat(finalPath); !os.IsNotExist(err) { + t.Fatalf("truncated download was promoted to the final path: %v", err) + } + if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) { + t.Fatalf("staged partial file left behind: %v", err) + } +}