diff --git a/pkg/base/model.go b/pkg/base/model.go index 7b49b297..e60bfffe 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -25,24 +25,6 @@ type Request struct { SkipVerifyCert bool `json:"skipVerifyCert"` } -// SetLabels replaces all labels on the request. -func (r *Request) SetLabels(labels map[string]string) { - r.Labels = labels -} - -// PutLabel sets a label on the request. -func (r *Request) PutLabel(key, value string) { - if r.Labels == nil { - r.Labels = make(map[string]string) - } - r.Labels[key] = value -} - -// DelLabel deletes a label from the request. -func (r *Request) DelLabel(key string) { - delete(r.Labels, key) -} - func (r *Request) Validate() error { if r.URL == "" { return fmt.Errorf("invalid request url") diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index 98ada366..545effb3 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -45,6 +45,32 @@ type generationTestManager struct { func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) { taskType := reflect.TypeOf((*Task)(nil)) + for _, typ := range []reflect.Type{taskType, reflect.TypeOf((*ExtensionTask)(nil)), reflect.TypeOf((*OnErrorExtensionTask)(nil))} { + for _, name := range []string{"SetUrl", "SetExtra", "PatchExtra", "PatchHeaders", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} { + if _, ok := typ.MethodByName(name); ok { + t.Fatalf("%v unexpectedly exposes %s", typ, name) + } + } + } + requestType := reflect.TypeOf((*ExtensionTaskRequest)(nil)) + for _, name := range []string{"SetUrl", "SetLabels", "PutLabel", "DelLabel", "SetMethod", "SetBody", "SetHeaders", "PutHeader", "DelHeader", "SetTrackers"} { + if _, ok := requestType.MethodByName(name); !ok { + t.Fatalf("request wrapper does not expose %s", name) + } + } + for _, typ := range []reflect.Type{reflect.TypeOf(ExtensionTask{}), reflect.TypeOf(OnErrorExtensionTask{})} { + if _, ok := typ.FieldByName("Req"); ok { + t.Fatalf("%v exposes the removed task.req entry", typ) + } + field, ok := typ.FieldByName("Meta") + if !ok || field.Type != reflect.TypeOf((*ExtensionTaskMeta)(nil)) { + t.Fatalf("%v has no metadata wrapper", typ) + } + } + field, ok := reflect.TypeOf(ExtensionTaskMeta{}).FieldByName("Req") + if !ok || field.Type != requestType { + t.Fatal("meta.req has no request wrapper") + } if _, ok := taskType.MethodByName("Continue"); ok { t.Fatal("regular task unexpectedly exposes Continue") } @@ -53,17 +79,11 @@ func TestExtensionTaskMethodsMatchEventCapabilities(t *testing.T) { } extensionTaskType := reflect.TypeOf((*ExtensionTask)(nil)) - if _, ok := extensionTaskType.MethodByName("SetUrl"); !ok { - t.Fatal("extension task does not expose SetUrl") - } if _, ok := extensionTaskType.MethodByName("Continue"); ok { t.Fatal("non-error extension task unexpectedly exposes Continue") } errorTaskType := reflect.TypeOf((*OnErrorExtensionTask)(nil)) - if _, ok := errorTaskType.MethodByName("SetUrl"); !ok { - t.Fatal("onError task does not expose SetUrl") - } if _, ok := errorTaskType.MethodByName("Continue"); !ok { t.Fatal("onError task does not expose Continue") } diff --git a/pkg/download/extension.go b/pkg/download/extension.go index 4c90af1b..29993cb5 100644 --- a/pkg/download/extension.go +++ b/pkg/download/extension.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/pkg/base" "github.com/GopeedLab/gopeed/pkg/download/engine" @@ -733,6 +734,14 @@ type OnDoneContext struct { // only some fields can be modified, such as request info. type ExtensionTask struct { *Task + Meta *ExtensionTaskMeta `json:"meta"` +} + +// ExtensionTaskMeta preserves metadata fields while exposing request operations +// at the existing meta.req path. +type ExtensionTaskMeta struct { + *fetcher.FetcherMeta + Req *ExtensionTaskRequest `json:"req"` } // OnErrorExtensionTask adds error-recovery controls to ExtensionTask. @@ -750,7 +759,14 @@ func cloneExtensionTask(task *Task) *Task { } func newExtensionTask(task *Task) *ExtensionTask { - return &ExtensionTask{Task: cloneExtensionTask(task)} + view := cloneExtensionTask(task) + return &ExtensionTask{ + Task: view, + Meta: &ExtensionTaskMeta{ + FetcherMeta: view.Meta, + Req: &ExtensionTaskRequest{Request: view.Meta.Req, task: view}, + }, + } } func newOnStartExtensionTask(task *Task) *ExtensionTask { @@ -768,11 +784,6 @@ func newOnDoneExtensionTask(task *Task) *Task { return cloneExtensionTask(task) } -// SetUrl replaces the task request URL. -func (t *ExtensionTask) SetUrl(url string) { - t.Meta.Req.URL = url -} - func (t *OnErrorExtensionTask) Continue() error { return t.download.Continue(&TaskFilter{ IDs: []string{t.ID}, diff --git a/pkg/download/extension_extra_mutation_test.go b/pkg/download/extension_extra_mutation_test.go new file mode 100644 index 00000000..d95ec369 --- /dev/null +++ b/pkg/download/extension_extra_mutation_test.go @@ -0,0 +1,188 @@ +package download + +import ( + "fmt" + "reflect" + "testing" + + "github.com/GopeedLab/gopeed/internal/fetcher" + "github.com/GopeedLab/gopeed/pkg/base" + btProtocol "github.com/GopeedLab/gopeed/pkg/protocol/bt" + httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http" + "github.com/dop251/goja" +) + +func TestExtensionProtocolMethods(t *testing.T) { + for _, extra := range []any{nil, map[string]any{"header": map[string]any{"Keep": "yes"}}, &httpProtocol.ReqExtra{Header: map[string]string{"Keep": "yes"}}} { + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + req := &base.Request{Extra: extra} + task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil { + t.Fatal(err) + } + _, err := vm.RunString(`task.meta.req.setMethod('POST'); task.meta.req.setBody('payload'); task.meta.req.putHeader('Authorization','Bearer token'); task.meta.req.putHeader('Remove','remove'); task.meta.req.delHeader('REMOVE');`) + if err != nil { + t.Fatal(err) + } + if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil { + t.Fatal(err) + } + got := req.Extra.(*httpProtocol.ReqExtra) + if got.Method != "POST" || got.Body != "payload" || got.Header["Authorization"] != "Bearer token" { + t.Fatalf("mutation not applied: %#v", got) + } + if _, ok := got.Header["Remove"]; ok { + t.Fatal("header deletion failed") + } + if extra != nil && got.Header["Keep"] != "yes" { + t.Fatal("existing header lost") + } + } + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + req := &base.Request{Extra: &btProtocol.ReqExtra{Trackers: []string{"old"}}} + task := &Task{Protocol: "bt", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil { + t.Fatal(err) + } + if _, err := vm.RunString(`task.meta.req.setTrackers(['new','another']);`); err != nil { + t.Fatal(err) + } + got := req.Extra.(*btProtocol.ReqExtra).Trackers + if len(got) != 2 || got[0] != "new" || got[1] != "another" { + t.Fatalf("trackers = %#v", got) + } +} + +func TestExtensionProtocolMethodsRejectInvalidMutation(t *testing.T) { + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + original := &httpProtocol.ReqExtra{Method: "POST", Header: map[string]string{"authorization": "old", "Keep": "yes"}} + req := &base.Request{Extra: original} + task := newOnStartExtensionTask(&Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}}) + if err := vm.Set("task", task); err != nil { + t.Fatal(err) + } + for _, script := range []string{`task.meta.req.setHeaders({Keep:null})`, `task.meta.req.setHeaders({Keep:123})`, `task.meta.req.setHeaders({a:'1',A:'2'})`} { + if _, err := vm.RunString(script); err == nil { + t.Fatalf("accepted invalid mutation: %s", script) + } + if req.Extra != original || original.Header["Keep"] != "yes" { + t.Fatal("invalid operation changed original extra") + } + } + if _, err := vm.RunString(`task.meta.req.putHeader('Authorization','new');`); err != nil { + t.Fatal(err) + } + got := req.Extra.(*httpProtocol.ReqExtra) + if len(got.Header) != 2 || got.Header["Authorization"] != "new" || original.Header["authorization"] != "old" { + t.Fatalf("header update = %#v", got.Header) + } + if _, err := vm.RunString(`task.meta.req.setHeaders({Only:'one'});`); err != nil { + t.Fatal(err) + } + got = req.Extra.(*httpProtocol.ReqExtra) + if got.Method != "POST" || len(got.Header) != 1 { + t.Fatalf("setHeaders = %#v", got) + } + req.Extra = map[string]any{"header": 123} + if _, err := vm.RunString(`task.meta.req.setBody('new')`); err == nil { + t.Fatal("invalid protocol extra accepted") + } + if req.Extra.(map[string]any)["header"] != 123 { + t.Fatal("conversion failure mutated extra") + } +} + +func TestExtensionProtocolMethodsOtherProtocolsAreNoOp(t *testing.T) { + for _, protocol := range []string{"http", "bt", "hls"} { + for _, onError := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/onError=%v", protocol, onError), func(t *testing.T) { + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + // An invalid extra proves that unrelated methods skip conversion entirely. + original := map[string]any{"unchanged": true, "header": 123} + req := &base.Request{Extra: original} + task := &Task{Protocol: protocol, Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + var wrapper any = newOnStartExtensionTask(task) + if onError { + wrapper = newOnErrorExtensionTask(nil, task) + } + if err := vm.Set("task", wrapper); err != nil { + t.Fatal(err) + } + if protocol != "http" { + if _, err := vm.RunString(`task.meta.req.setMethod('POST');task.meta.req.setBody('body');task.meta.req.setHeaders({a:null});task.meta.req.putHeader('A','value');task.meta.req.delHeader('A');`); err != nil { + t.Fatal(err) + } + } + if protocol != "bt" { + if _, err := vm.RunString(`task.meta.req.setTrackers(['udp://example.com']);`); err != nil { + t.Fatal(err) + } + } + if !reflect.DeepEqual(req.Extra, original) { + t.Fatalf("extra changed: %#v", req.Extra) + } + original["same-object"] = true + if req.Extra.(map[string]any)["same-object"] != true { + t.Fatal("no-op replaced extra") + } + }) + } + } +} + +func TestExtensionOnErrorRequestWritesToOriginalTask(t *testing.T) { + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + req := &base.Request{URL: "https://example.com", Extra: &httpProtocol.ReqExtra{Header: map[string]string{"Keep": "yes"}}} + task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + if err := vm.Set("task", newOnErrorExtensionTask(nil, task)); err != nil { + t.Fatal(err) + } + if _, err := vm.RunString(` + if (typeof task.continue !== 'function') throw new Error('missing continue'); + if (typeof task.putHeader !== 'undefined') throw new Error('task still exposes protocol methods'); + if (typeof task.req !== 'undefined') throw new Error('legacy task.req'); + const request = task.meta.req; + if (request.url !== 'https://example.com' || request.extra.header.Keep !== 'yes') throw new Error('request data missing'); + if (!task.meta.opts) throw new Error('metadata fields missing'); + request.setHeaders({Keep:'new'}); + request.putHeader('Authorization','Bearer new'); + request.setMethod('POST'); + if (typeof task.setUrl !== 'undefined' || typeof task.extra !== 'undefined') throw new Error('legacy task API'); + request.setUrl('https://example.com/new'); + request.setLabels({keep:'yes',remove:'yes'}); + request.putLabel('new','value'); + request.delLabel('remove'); + if (request.url !== 'https://example.com/new' || request.labels.new !== 'value' || request.extra.method !== 'POST' || request.extra.header.Authorization !== 'Bearer new') throw new Error('request data did not refresh'); + `); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(req.Labels, map[string]string{"keep": "yes", "new": "value"}) { + t.Fatalf("labels = %#v", req.Labels) + } + got := req.Extra.(*httpProtocol.ReqExtra) + if got.Method != "POST" || got.Header["Keep"] != "new" || got.Header["Authorization"] != "Bearer new" || req.URL != "https://example.com/new" { + t.Fatalf("original request not updated: %#v, %#v", req, got) + } +} + +func TestExtensionBTRequestInitializesNilExtra(t *testing.T) { + vm := goja.New() + vm.SetFieldNameMapper(goja.TagFieldNameMapper("json", true)) + req := &base.Request{} + task := &Task{Protocol: "bt", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + if err := vm.Set("task", newOnStartExtensionTask(task)); err != nil { + t.Fatal(err) + } + if _, err := vm.RunString(`task.meta.req.setTrackers(['udp://example.com']);`); err != nil { + t.Fatal(err) + } + got, ok := req.Extra.(*btProtocol.ReqExtra) + if !ok || !reflect.DeepEqual(got.Trackers, []string{"udp://example.com"}) { + t.Fatalf("extra = %#v", req.Extra) + } +} diff --git a/pkg/download/extension_extra_test.go b/pkg/download/extension_extra_test.go new file mode 100644 index 00000000..d298946a --- /dev/null +++ b/pkg/download/extension_extra_test.go @@ -0,0 +1,104 @@ +package download + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" + httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http" +) + +func TestExtensionExtraMutationHTTP(t *testing.T) { + for _, resolved := range []bool{false, true} { + for _, invalid := range []bool{false, true} { + t.Run(fmt.Sprintf("resolved=%v/invalid=%v", resolved, invalid), func(t *testing.T) { + var updatedRequests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-New") == "new" { + updatedRequests.Add(1) + if r.Header.Get("X-Old") != "" { + t.Error("extra mutation retained the old header") + } + } + http.ServeContent(w, r, "test.txt", time.Time{}, strings.NewReader("extra mutation payload")) + })) + defer server.Close() + d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()}) + if err := d.Setup(); err != nil { + t.Fatal(err) + } + defer d.Clear() + extDir := t.TempDir() + manifest := `{"name":"http-methods","title":"HTTP methods test","version":"0.0.1","scripts":[{"event":"onStart","match":{"labels":["test"]},"entry":"index.js"}]}` + mutation := `await ctx.task.meta.req.setMethod('GET'); + await ctx.task.meta.req.setBody(''); + await ctx.task.meta.req.delHeader('x-old'); + await ctx.task.meta.req.putHeader('X-New', 'new');` + if invalid { + mutation = `try { await ctx.task.meta.req.setHeaders({'X-New':123}); } + catch (err) { await ctx.task.meta.req.putLabel('rejected','true'); } ` + mutation + } + script := `gopeed.events.onStart(async ctx => { ` + mutation + ` });` + for name, content := range map[string]string{"manifest.json": manifest, "index.js": script} { + if err := os.WriteFile(filepath.Join(extDir, name), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + if _, err := d.InstallExtensionByFolder(extDir, false); err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + d.Listener(func(event *Event) { + if event.Key == EventKeyFinally { + done <- event.Err + } + }) + req := &base.Request{URL: server.URL + "/test.txt", Labels: map[string]string{"test": "true"}, Extra: &httpProtocol.ReqExtra{Header: map[string]string{"X-Old": "old"}}} + opts := &base.Options{Path: t.TempDir()} + var id string + var err error + if resolved { + rr, resolveErr := d.Resolve(req, opts) + if resolveErr != nil { + t.Fatal(resolveErr) + } + id, err = d.Create(rr.ID) + } else { + id, err = d.CreateDirect(req, opts) + } + if err != nil { + t.Fatal(err) + } + select { + case err = <-done: + case <-time.After(10 * time.Second): + t.Fatal("download timed out") + } + if invalid && d.GetTask(id).Meta.Req.Labels["rejected"] != "true" { + t.Fatal("invalid header was not rejected") + } + if err != nil { + t.Fatal(err) + } + got, ok := d.GetTask(id).Meta.Req.Extra.(*httpProtocol.ReqExtra) + if !ok || got.Header["X-New"] != "new" || len(got.Header) != 1 { + t.Fatalf("unexpected protocol extra: %#v", d.GetTask(id).Meta.Req.Extra) + } + if !resolved && updatedRequests.Load() == 0 { + t.Fatal("download did not use the extension headers") + } + data, err := os.ReadFile(filepath.Join(opts.Path, "test.txt")) + if err != nil || string(data) != "extra mutation payload" { + t.Fatalf("download content = %q, error = %v", data, err) + } + }) + } + } +} diff --git a/pkg/download/extension_task_request.go b/pkg/download/extension_task_request.go new file mode 100644 index 00000000..2e26ce97 --- /dev/null +++ b/pkg/download/extension_task_request.go @@ -0,0 +1,140 @@ +package download + +import ( + "encoding/json" + "fmt" + "net/http" + "strings" + + "github.com/GopeedLab/gopeed/pkg/base" + btProtocol "github.com/GopeedLab/gopeed/pkg/protocol/bt" + httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http" + "github.com/GopeedLab/gopeed/pkg/util" + "github.com/dop251/goja" +) + +// ExtensionTaskRequest exposes request operations without exposing the backing task. +// It always reads the current request so a retained handle sees subsequent updates. +type ExtensionTaskRequest struct { + *base.Request + task *Task +} + +// SetUrl replaces the task request URL. +func (r *ExtensionTaskRequest) SetUrl(url string) { r.task.Meta.Req.URL = url } + +// SetLabels replaces all labels on the request. +func (r *ExtensionTaskRequest) SetLabels(labels map[string]string) { + r.Labels = labels +} + +// PutLabel sets a label on the request. +func (r *ExtensionTaskRequest) PutLabel(key, value string) { + if r.Labels == nil { + r.Labels = make(map[string]string) + } + r.Labels[key] = value +} + +// DelLabel deletes a label from the request. +func (r *ExtensionTaskRequest) DelLabel(key string) { + delete(r.Labels, key) +} + +// mutateTaskExtra converts into an independent protocol value before mutation. +// Other protocols are a no-op. Failed conversion or mutation must not change the live request. +func mutateTaskExtra[E any](task *Task, protocol string, mutate func(*E) error) error { + if task.Protocol != protocol { + return nil + } + if task.Meta == nil || task.Meta.Req == nil { + return fmt.Errorf("task request is missing") + } + var extra E + if err := util.MapToStruct(task.Meta.Req.Extra, &extra); err != nil { + return err + } + if err := mutate(&extra); err != nil { + return err + } + task.Meta.Req.Extra = &extra + return nil +} + +func (t *ExtensionTaskRequest) SetMethod(method string) error { + return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { extra.Method = method; return nil }) +} + +func (t *ExtensionTaskRequest) SetBody(body string) error { + return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { extra.Body = body; return nil }) +} + +// decodeHeaders omits undefined properties and rejects non-string values. +func decodeHeaders(value *goja.Object) (map[string]string, error) { + if value == nil { + return nil, fmt.Errorf("headers must be an object") + } + data, err := value.MarshalJSON() + if err != nil { + return nil, err + } + var input map[string]*string + if err := json.Unmarshal(data, &input); err != nil { + return nil, err + } + if input == nil { + return nil, fmt.Errorf("headers must be an object") + } + headers := make(map[string]string, len(input)) + seen := make(map[string]bool, len(input)) + for name, value := range input { + if value == nil { + return nil, fmt.Errorf("header %q must be a string", name) + } + lower := strings.ToLower(name) + if seen[lower] { + return nil, fmt.Errorf("duplicate header name %q", name) + } + seen[lower] = true + headers[http.CanonicalHeaderKey(name)] = *value + } + return headers, nil +} + +func (t *ExtensionTaskRequest) SetHeaders(headers *goja.Object) error { + return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { + values, err := decodeHeaders(headers) + if err != nil { + return err + } + extra.Header = values + return nil + }) +} + +func (t *ExtensionTaskRequest) PutHeader(name, value string) error { + return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { + if extra.Header == nil { + extra.Header = make(map[string]string) + } + deleteHeader(extra.Header, name) + extra.Header[http.CanonicalHeaderKey(name)] = value + return nil + }) +} + +func (t *ExtensionTaskRequest) DelHeader(name string) error { + return mutateTaskExtra(t.task, "http", func(extra *httpProtocol.ReqExtra) error { deleteHeader(extra.Header, name); return nil }) +} + +func deleteHeader(headers map[string]string, name string) { + for key := range headers { + if strings.EqualFold(key, name) { + delete(headers, key) + } + } +} + +func (t *ExtensionTaskRequest) SetTrackers(trackers []string) error { + return mutateTaskExtra(t.task, "bt", func(extra *btProtocol.ReqExtra) error { extra.Trackers = append([]string{}, trackers...); return nil }) +} diff --git a/pkg/download/extension_test.go b/pkg/download/extension_test.go index ef5ff8bd..e1f81ab3 100644 --- a/pkg/download/extension_test.go +++ b/pkg/download/extension_test.go @@ -14,10 +14,12 @@ import ( "testing" "time" + "github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/logger" "github.com/GopeedLab/gopeed/pkg/base" gojaerror "github.com/GopeedLab/gopeed/pkg/download/engine/inject/error" enginewebview "github.com/GopeedLab/gopeed/pkg/download/engine/webview" + httpProtocol "github.com/GopeedLab/gopeed/pkg/protocol/http" "github.com/dop251/goja" ) @@ -38,13 +40,13 @@ func TestDownloader_InstallExtensionByFolder(t *testing.T) { t.Fatal("resolve error") } if req.Labels["replaced"] != "true" || req.Labels["modified"] != "true" { - t.Fatalf("request label methods did not update labels: %#v", req.Labels) + t.Fatalf("declarative labels did not update: %#v", req.Labels) } if _, ok := req.Labels["original"]; ok { - t.Fatalf("setLabels did not replace existing labels: %#v", req.Labels) + t.Fatalf("label assignment did not replace existing labels: %#v", req.Labels) } if _, ok := req.Labels["removed"]; ok { - t.Fatalf("delLabel did not remove label: %#v", req.Labels) + t.Fatalf("declarative label deletion failed: %#v", req.Labels) } }) } @@ -1188,6 +1190,37 @@ func TestDownloader_UpgradeExtension(t *testing.T) { }) } +func TestDownloader_Extension_OnStartExtraMutation(t *testing.T) { + d := NewDownloader(&DownloaderConfig{Storage: NewMemStorage(), StorageDir: t.TempDir()}) + if err := d.Setup(); err != nil { + t.Fatal(err) + } + defer d.Clear() + if _, err := d.InstallExtensionByFolder("./testdata/extensions/on_start", false); err != nil { + t.Fatal(err) + } + for _, extra := range []any{nil, &httpProtocol.ReqExtra{ + Method: "POST", Body: "old body", Header: map[string]string{"Old": "value"}, + }} { + req := &base.Request{URL: "https://github.com/test", Extra: extra} + task := &Task{Protocol: "http", Meta: &fetcher.FetcherMeta{Req: req, Opts: &base.Options{}}} + d.triggerOnStart(task) + if err := base.ParseReqExtra[httpProtocol.ReqExtra](req); err != nil { + t.Fatal(err) + } + got, ok := req.Extra.(*httpProtocol.ReqExtra) + if !ok || got.Header["X-Gopeed-Test"] != "on-start" { + t.Fatalf("setHeaders did not update the original request: %#v", req.Extra) + } + if len(got.Header) != 1 { + t.Fatalf("headers not replaced: %#v", got) + } + if original, ok := extra.(*httpProtocol.ReqExtra); ok && (got.Method != original.Method || got.Body != original.Body) { + t.Fatalf("setHeaders changed unrelated fields: %#v", got) + } + } +} + func TestDownloader_Extension_OnStart(t *testing.T) { downloadAndCheck := func(req *base.Request) { setupDownloader(func(downloader *Downloader) { diff --git a/pkg/download/testdata/extensions/basic/index.js b/pkg/download/testdata/extensions/basic/index.js index cfbde77b..eae932f4 100644 --- a/pkg/download/testdata/extensions/basic/index.js +++ b/pkg/download/testdata/extensions/basic/index.js @@ -1,7 +1,10 @@ gopeed.events.onResolve(async function (ctx) { - await ctx.req.setLabels({ replaced: "true", removed: "true" }); - await ctx.req.putLabel("modified", "true"); - await ctx.req.delLabel("removed"); + for (const method of ['setUrl', 'setLabels', 'putLabel', 'delLabel', 'setMethod', 'putHeader', 'setTrackers']) { + if (typeof ctx.req[method] !== 'undefined') throw new Error(`onResolve exposes ${method}`); + } + ctx.req.labels = { replaced: "true", removed: "true" }; + ctx.req.labels.modified = "true"; + delete ctx.req.labels.removed; ctx.res = { name: "test", files: Array(2).fill(true).map((_, i) => ({ diff --git a/pkg/download/testdata/extensions/blob_pause_rebuild/index.js b/pkg/download/testdata/extensions/blob_pause_rebuild/index.js index 4b3fbabc..d3d80520 100644 --- a/pkg/download/testdata/extensions/blob_pause_rebuild/index.js +++ b/pkg/download/testdata/extensions/blob_pause_rebuild/index.js @@ -132,7 +132,7 @@ gopeed.events.onError(async function (ctx) { return; } - await ctx.task.setUrl(await createPayloadURL(source, false)); + await ctx.task.meta.req.setUrl(await createPayloadURL(source, false)); await req.putLabel('started', 'true'); await req.putLabel('rebuilt', 'true'); await ctx.task.continue(); diff --git a/pkg/download/testdata/extensions/blob_recover/index.js b/pkg/download/testdata/extensions/blob_recover/index.js index 7a6c4926..c137bc0d 100644 --- a/pkg/download/testdata/extensions/blob_recover/index.js +++ b/pkg/download/testdata/extensions/blob_recover/index.js @@ -41,6 +41,6 @@ gopeed.events.onError(async function (ctx) { } await req.putLabel("recovered", "true"); - await ctx.task.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" }))); + await ctx.task.meta.req.setUrl(await gopeed.runtime.blob.createObjectURL(new Blob(["ok\n"], { type: "text/plain" }))); await ctx.task.continue(); }); diff --git a/pkg/download/testdata/extensions/blob_restart/index.js b/pkg/download/testdata/extensions/blob_restart/index.js index b640f674..57cac8a0 100644 --- a/pkg/download/testdata/extensions/blob_restart/index.js +++ b/pkg/download/testdata/extensions/blob_restart/index.js @@ -46,7 +46,7 @@ gopeed.events.onError(async function (ctx) { } try { - await ctx.task.setUrl(await createRangeBlobUrl(req.rawUrl)); + await ctx.task.meta.req.setUrl(await createRangeBlobUrl(req.rawUrl)); await req.putLabel('started', 'true'); await req.putLabel('rebuilt', 'true'); await ctx.task.continue(); diff --git a/pkg/download/testdata/extensions/on_error/index.js b/pkg/download/testdata/extensions/on_error/index.js index e5fa2955..3dde4371 100644 --- a/pkg/download/testdata/extensions/on_error/index.js +++ b/pkg/download/testdata/extensions/on_error/index.js @@ -1,6 +1,6 @@ gopeed.events.onError(async function (ctx) { gopeed.logger.info("url", ctx.task.meta.req.url); gopeed.logger.info("error", ctx.error); - await ctx.task.setUrl("https://github.com"); + await ctx.task.meta.req.setUrl("https://github.com"); await ctx.task.continue(); }); diff --git a/pkg/download/testdata/extensions/on_start/index.js b/pkg/download/testdata/extensions/on_start/index.js index 59370be3..a882db24 100644 --- a/pkg/download/testdata/extensions/on_start/index.js +++ b/pkg/download/testdata/extensions/on_start/index.js @@ -1,5 +1,6 @@ gopeed.events.onStart(async function (ctx) { gopeed.logger.info("url", ctx.task.meta.req.url); - await ctx.task.setUrl("https://github.com"); + await ctx.task.meta.req.setUrl("https://github.com"); + await ctx.task.meta.req.setHeaders({ "X-Gopeed-Test": "on-start" }); await ctx.task.meta.req.putLabel('modified', 'true'); });