diff --git a/go_backend/download_output_staging.go b/go_backend/download_output_staging.go new file mode 100644 index 00000000..4c8a0b0e --- /dev/null +++ b/go_backend/download_output_staging.go @@ -0,0 +1,33 @@ +package gobackend + +import ( + "path/filepath" + "strings" + "sync" +) + +// downloadPathLocks serializes writes per final output path so concurrent +// downloads that resolve to the same file cannot interleave bytes into one +// output or race the staged-promote rename. Keys are normalized case-folded +// cleaned paths; entries live for the process lifetime (bounded by the number +// of distinct output files in a session). +var downloadPathLocks sync.Map + +// lockDownloadOutputPath locks the given final output path and returns the +// unlock function. Different paths keep downloading in parallel; a second +// download of the same path blocks until the first finishes. +func lockDownloadOutputPath(path string) func() { + key := strings.ToLower(filepath.Clean(path)) + value, _ := downloadPathLocks.LoadOrStore(key, &sync.Mutex{}) + mu := value.(*sync.Mutex) + mu.Lock() + return mu.Unlock +} + +// stagedDownloadPath returns the sibling name downloads are streamed into +// before being promoted to the final path with an atomic rename. The suffix +// keeps the staged file invisible to extension-based duplicate checks, which +// match on the final audio extension. +func stagedDownloadPath(finalPath string) string { + return finalPath + ".partial" +} diff --git a/go_backend/extension_runtime_file.go b/go_backend/extension_runtime_file.go index 0ae51856..9bce493d 100644 --- a/go_backend/extension_runtime_file.go +++ b/go_backend/extension_runtime_file.go @@ -207,6 +207,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { return r.fileDownloadChunked(client, urlStr, fullPath, headers, ua, chunkSize, onProgress, trackItemBytes) } + unlock := lockDownloadOutputPath(fullPath) + defer unlock() + req, err := http.NewRequest("GET", urlStr, nil) if err != nil { return r.vm.ToValue(map[string]any{ @@ -239,14 +242,25 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { }) } - out, err := os.Create(fullPath) + // Stream into a staged sibling and promote via rename on success so a + // killed process can never leave a partial file under the final name + // (the duplicate check would then accept it as complete forever). + stagedPath := stagedDownloadPath(fullPath) + os.Remove(stagedPath) + out, err := os.Create(stagedPath) if err != nil { return r.vm.ToValue(map[string]any{ "success": false, "error": fmt.Sprintf("failed to create file: %v", err), }) } - defer out.Close() + promoted := false + defer func() { + out.Close() + if !promoted { + os.Remove(stagedPath) + } + }() activeItemID := r.getActiveDownloadItemID() if activeItemID != "" { @@ -319,6 +333,20 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { } } + if err := out.Close(); err != nil { + return r.vm.ToValue(map[string]any{ + "success": false, + "error": fmt.Sprintf("failed to finalize file: %v", err), + }) + } + if err := os.Rename(stagedPath, fullPath); err != nil { + return r.vm.ToValue(map[string]any{ + "success": false, + "error": fmt.Sprintf("failed to publish file: %v", err), + }) + } + promoted = true + GoLog("[Extension:%s] Downloaded %d bytes to %s\n", r.extensionID, written, fullPath) return r.vm.ToValue(map[string]any{ @@ -332,6 +360,9 @@ func (r *extensionRuntime) fileDownload(call goja.FunctionCall) goja.Value { // This is needed for servers (like YouTube's googlevideo CDN) that reject // non-ranged or large-range requests with 403 and require small chunk downloads. func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, fullPath string, headers map[string]string, ua string, chunkSize int64, onProgress goja.Callable, trackItemBytes bool) goja.Value { + unlock := lockDownloadOutputPath(fullPath) + defer unlock() + // First, get the total content length with a small probe request probeReq, err := http.NewRequest("GET", urlStr, nil) if err != nil { @@ -386,14 +417,24 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full GoLog("[Extension:%s] Chunked download: total size %d bytes, chunk size %d\n", r.extensionID, totalSize, chunkSize) } - out, err := os.Create(fullPath) + // Same staged-write-then-promote protocol as fileDownload: never leave a + // partial file under the final name. + stagedPath := stagedDownloadPath(fullPath) + os.Remove(stagedPath) + out, err := os.Create(stagedPath) if err != nil { return r.vm.ToValue(map[string]any{ "success": false, "error": fmt.Sprintf("failed to create file: %v", err), }) } - defer out.Close() + promoted := false + defer func() { + out.Close() + if !promoted { + os.Remove(stagedPath) + } + }() activeItemID := r.getActiveDownloadItemID() if activeItemID != "" { @@ -549,6 +590,20 @@ func (r *extensionRuntime) fileDownloadChunked(client *http.Client, urlStr, full } } + if err := out.Close(); err != nil { + return r.vm.ToValue(map[string]any{ + "success": false, + "error": fmt.Sprintf("failed to finalize file: %v", err), + }) + } + if err := os.Rename(stagedPath, fullPath); err != nil { + return r.vm.ToValue(map[string]any{ + "success": false, + "error": fmt.Sprintf("failed to publish file: %v", err), + }) + } + promoted = true + GoLog("[Extension:%s] Chunked download complete: %d bytes to %s\n", r.extensionID, totalWritten, fullPath) return r.vm.ToValue(map[string]any{ @@ -770,7 +825,18 @@ func (r *extensionRuntime) fileWrite(call goja.FunctionCall) goja.Value { }) } - if err := os.WriteFile(fullPath, []byte(data), 0644); err != nil { + // Full-content write: stage and rename so a kill mid-write cannot leave + // a truncated file under the final name. + stagedPath := stagedDownloadPath(fullPath) + if err := os.WriteFile(stagedPath, []byte(data), 0644); err != nil { + os.Remove(stagedPath) + return r.vm.ToValue(map[string]any{ + "success": false, + "error": err.Error(), + }) + } + if err := os.Rename(stagedPath, fullPath); err != nil { + os.Remove(stagedPath) return r.vm.ToValue(map[string]any{ "success": false, "error": err.Error(), diff --git a/go_backend/extension_runtime_supplement_test.go b/go_backend/extension_runtime_supplement_test.go index 785387d5..b3ebf5a6 100644 --- a/go_backend/extension_runtime_supplement_test.go +++ b/go_backend/extension_runtime_supplement_test.go @@ -178,6 +178,95 @@ func TestExtensionRuntimeAuthAndPolyfills(t *testing.T) { } } +type failingBodyReader struct { + data []byte + sent bool +} + +func (f *failingBodyReader) Read(p []byte) (int, error) { + if !f.sent { + f.sent = true + n := copy(p, f.data) + return n, nil + } + return 0, fmt.Errorf("connection reset") +} + +func newFileDownloadTestRuntime(t *testing.T, transport roundTripFunc) *extensionRuntime { + t.Helper() + return &extensionRuntime{ + extensionID: "dl-ext", + manifest: &ExtensionManifest{ + Name: "dl-ext", + Version: "1.0.0", + Permissions: ExtensionPermissions{ + File: true, + Network: []string{"cdn.example.com"}, + }, + }, + dataDir: t.TempDir(), + vm: goja.New(), + httpClient: &http.Client{Transport: transport}, + } +} + +func TestFileDownloadStagesAndPromotes(t *testing.T) { + runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader("audio-bytes")), + ContentLength: int64(len("audio-bytes")), + 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"] != true { + t.Fatalf("download result = %#v", result) + } + + finalPath := filepath.Join(runtime.dataDir, "out", "track.flac") + data, err := os.ReadFile(finalPath) + if err != nil || string(data) != "audio-bytes" { + t.Fatalf("final file = %q/%v", data, err) + } + if _, err := os.Stat(stagedDownloadPath(finalPath)); !os.IsNotExist(err) { + t.Fatalf("staged file left behind: %v", err) + } +} + +func TestFileDownloadFailureLeavesNoFinalFile(t *testing.T) { + runtime := newFileDownloadTestRuntime(t, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Header: make(http.Header), + Body: io.NopCloser(&failingBodyReader{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 failed download, got %#v", result) + } + + 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 TestParseExtensionTrackValueExplicit(t *testing.T) { vm := goja.New()